diff --git a/CHANGES b/CHANGES index 3798c5eb3..cca07000e 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,34 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### Experimental operations and engines (#690) + +Operations describe tmux commands as data. Each renders its argv against a tmux +version (dropping flags an older tmux cannot accept), adapts raw output into a +typed result, and serializes to and from plain dicts -- all without a running +tmux server. The set spans the read seam (``list-*``, ``has-session``, +``display-message``, ``show-options``, ``show-buffer``) and the +mutating/creating surface for panes, windows, the server, options, environment, +hooks, and paste buffers. A registry-generated catalog on the {ref}`experimental` +page always matches the code. + +Engines run those operations behind one protocol, so the same operation returns +the same typed result whether it goes through a subprocess (the classic path +that reproduces today's libtmux behavior), an in-memory simulator for tests and +dry runs, a persistent ``tmux -C`` control connection, an async transport, or +tmux's native binary peer protocol. Results never raise on construction; +raising is opt-in via ``raise_for_status()``, and how a failed result is handled +is each engine's policy. + +A {class}`~libtmux.experimental.ops.plan.LazyPlan` records operations and yields +forward references so a later operation can target an object that does not exist +yet, resolved against captured ids at execution time. How a plan becomes tmux +dispatches is a pluggable {class}`~libtmux.experimental.ops.planner.Planner` +(sequential, ``;``-folding, or ``{marked}``-folding), so dispatch strategies can +be A/B tested against the same plan with identical results. + ## libtmux 0.58.1 (2026-06-16) libtmux 0.58.1 restores compatibility with pytest 9.1. The bundled diff --git a/docs/_ext/tmuxop.py b/docs/_ext/tmuxop.py new file mode 100644 index 000000000..550c0bbd8 --- /dev/null +++ b/docs/_ext/tmuxop.py @@ -0,0 +1,120 @@ +"""Sphinx directive that renders the experimental operation catalog. + +``.. tmuxop-catalog::`` (or the MyST fenced form) walks +:func:`libtmux.experimental.ops.catalog` and emits a table of operations with +their scope, safety tier, result type, minimum tmux version, and summary. The +operation registry is the single source of truth, so the rendered reference +cannot drift from the code. + +Options +------- +``:scope:`` / ``:safety:`` + Filter to one scope (``pane``/``window``/``session``/``server``/``client``) + or safety tier (``readonly``/``mutating``/``destructive``). +``:primitive-only:`` + Show only operations that wrap a single tmux command. + +This is the in-repo renderer; a full gp-sphinx ``tmuxop`` domain (cross-reference +roles + an operations index) can later replace it under the same directive name. +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + +_HEADERS = ("Operation", "Command", "Scope", "Safety", "Result", "Min tmux", "Summary") + + +def _row(cells: Sequence[str]) -> nodes.row: + """Build a docutils table row from string cells.""" + row = nodes.row() + for cell in cells: + entry = nodes.entry() + entry += nodes.paragraph(text=cell) + row += entry + return row + + +def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> nodes.table: + """Build a simple docutils table.""" + table = nodes.table() + tgroup = nodes.tgroup(cols=len(headers)) + table += tgroup + for _ in headers: + tgroup += nodes.colspec(colwidth=1) + thead = nodes.thead() + thead += _row(headers) + tgroup += thead + tbody = nodes.tbody() + for row in rows: + tbody += _row(row) + tgroup += tbody + return table + + +class TmuxopCatalogDirective(SphinxDirective): + """Render the operation catalog as a table.""" + + has_content = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "scope": directives.unchanged, + "safety": directives.unchanged, + "primitive-only": directives.flag, + } + + def run(self) -> list[nodes.Node]: + """Build the catalog table from the operation registry.""" + from libtmux.experimental.ops import catalog + + entries = catalog() + scope = self.options.get("scope") + safety = self.options.get("safety") + if scope: + entries = [entry for entry in entries if entry.scope == scope] + if safety: + entries = [entry for entry in entries if entry.safety == safety] + if "primitive-only" in self.options: + entries = [entry for entry in entries if entry.primitive] + + if not entries: + logger.warning( + "tmuxop-catalog: no operations matched the given filters", + location=self.get_location(), + ) + return [] + + rows = [ + ( + entry.kind, + entry.command, + entry.scope, + entry.safety, + entry.result_type, + entry.min_version or "-", + entry.summary, + ) + for entry in entries + ] + return [_table(_HEADERS, rows)] + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the directive.""" + app.add_directive("tmuxop-catalog", TmuxopCatalogDirective) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/conf.py b/docs/conf.py index e902a2398..e67c45c81 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ project_src = project_root / "src" sys.path.insert(0, str(project_src)) +sys.path.insert(0, str(cwd / "_ext")) # package data about: dict[str, str] = {} @@ -34,6 +35,7 @@ "sphinx_autodoc_api_style", "sphinx_autodoc_pytest_fixtures", "sphinx.ext.todo", + "tmuxop", ], intersphinx_mapping={ "python": ("https://docs.python.org/", None), diff --git a/docs/experimental.md b/docs/experimental.md new file mode 100644 index 000000000..0cc6fbe65 --- /dev/null +++ b/docs/experimental.md @@ -0,0 +1,136 @@ +(experimental)= + +# Experimental: operations & engines + +```{warning} +Everything under {mod}`libtmux.experimental` is **not** covered by the +versioning policy and may change or be removed between any two releases. +``` + +`libtmux.experimental` hosts an inert, typed *operation* substrate and the +*engines* that execute it. An operation describes a tmux command (it renders +argv, carries its result type and metadata, and serializes) without dispatching; +an engine runs operations and returns typed results. The same operation returns +the same typed result whether executed by a subprocess, an in-memory simulator, +a persistent `tmux -C` control connection, or an async transport. + +See ``tmux-python/libtmux`` issue 689 for the operationalization plan. + +## Running an operation + +An operation is a value; ``run`` (or ``arun`` for async) hands it to an engine +and returns the engine's typed result. Results never raise on construction -- +inspect ``ok``/``status``, or opt into raising with ``raise_for_status()``: + +```python +>>> from libtmux.experimental.ops import HasSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> from libtmux.experimental.engines import ConcreteEngine +>>> result = run(HasSession(target=SessionId("$0")), ConcreteEngine()) +>>> result.ok +True +>>> result.raise_for_status() is result +True +``` + +How a *failed* result is treated is the engine's policy: the classic subprocess +path raises in its facade to match today's libtmux behavior, while the newer +engines hand the result back and let the caller decide. + +## Choosing an engine + +Every engine satisfies the same ``TmuxEngine`` (or ``AsyncTmuxEngine``) +protocol, so swapping engines never changes an operation or its result type -- +only *how* and *where* the command runs. + +| Engine | Transport | Use it for | +| --- | --- | --- | +| ``SubprocessEngine`` | one ``tmux`` process per command | the classic path; reproduces today's libtmux behavior | +| ``ConcreteEngine`` | in-memory, no tmux | tests and dry runs (deterministic, fabricated output) | +| ``ControlModeEngine`` | a persistent ``tmux -C`` connection | many commands over one long-lived session | +| ``ImsgEngine`` | tmux's native binary peer protocol | an opt-in easter egg | + +Each has an ``Async*`` counterpart (``AsyncSubprocessEngine``, +``AsyncConcreteEngine``, ``AsyncControlModeEngine``) behind ``AsyncTmuxEngine``. +Construct one directly, bind it to a live server with +``SubprocessEngine.for_server(server)``, or select one by name from the engine +registry: + +```python +>>> from libtmux.experimental.engines import available_engines, create_engine +>>> from libtmux.experimental.ops import HasSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> available_engines() +('concrete', 'control_mode', 'imsg', 'subprocess') +>>> engine = create_engine("concrete") +>>> run(HasSession(target=SessionId("$0")), engine).status +'complete' +``` + +## Lazy plans and planners + +A {class}`~libtmux.experimental.ops.plan.LazyPlan` records operations without +running them, returning a forward *slot reference* for each created object so a +later operation can target something that does not exist yet. ``execute`` +(or ``aexecute``) resolves those references against captured ids as it goes: + +```python +>>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys +>>> from libtmux.experimental.ops._types import WindowId +>>> from libtmux.experimental.engines import ConcreteEngine +>>> plan = LazyPlan() +>>> pane = plan.add(SplitWindow(target=WindowId("@1"))) +>>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True)) +>>> outcome = plan.execute(ConcreteEngine()) +>>> outcome.ok +True +>>> [r.status for r in outcome.results] +['complete', 'complete'] +``` + +Operations also compose with ``>>`` into a chain, which a plan can run as one +dispatch when the members are chainable. + +*How* a plan turns into dispatches is a pluggable +{class}`~libtmux.experimental.ops.planner.Planner`, so strategies can be A/B +tested against the same plan: + +- ``SequentialPlanner`` -- one dispatch per operation (the default). +- ``FoldingPlanner`` -- folds adjacent chainable operations into a single + ``;``-separated dispatch. +- ``MarkedPlanner`` -- folds a "create then decorate the new pane" run into one + dispatch using tmux's ``{marked}`` register. + +Every planner produces the same per-operation result; they differ only in how +many times tmux is invoked: + +```python +>>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys, FoldingPlanner +>>> from libtmux.experimental.ops._types import WindowId +>>> from libtmux.experimental.engines import ConcreteEngine +>>> plan = LazyPlan() +>>> pane = plan.add(SplitWindow(target=WindowId("@1"))) +>>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True)) +>>> plan.execute(ConcreteEngine(), planner=FoldingPlanner()).ok +True +``` + +## Operation catalog + +The catalog below is generated from the operation registry, so it always matches +the code. + +```{tmuxop-catalog} +``` + +### Read-only operations + +```{tmuxop-catalog} +:safety: readonly +``` + +### Destructive operations + +```{tmuxop-catalog} +:safety: destructive +``` diff --git a/docs/index.md b/docs/index.md index f6e763e0c..ad59a9c1b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -102,6 +102,7 @@ api/index api/testing/index internals/index project/index +experimental history migration glossary diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index c0d00eb79..65b723420 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -76,13 +76,18 @@ True ### Waiting for specific output +> **Note:** This polls with `capture_pane` + `sleep` — correct for the +> synchronous library. If you drive tmux through the libtmux MCP server, prefer +> the event-backed `wait_for_output` tool instead: it folds live `%output` and +> returns when the pane settles, with no polling. + ```python >>> import time >>> monitor_window = session.new_window(window_name='monitor', attach=False) >>> monitor_pane = monitor_window.active_pane ->>> def wait_for_output(pane, text, timeout=5.0, poll_interval=0.1): +>>> def wait_for_text(pane, text, timeout=5.0, poll_interval=0.1): ... """Wait for specific text to appear in pane output.""" ... start = time.time() ... while time.time() - start < timeout: @@ -93,7 +98,7 @@ True ... return False >>> monitor_pane.send_keys('sleep 0.2; echo "READY"') ->>> wait_for_output(monitor_pane, 'READY', timeout=2.0) +>>> wait_for_text(monitor_pane, 'READY', timeout=2.0) True >>> # Clean up diff --git a/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/pyproject.toml b/pyproject.toml index 2575789c4..758eccc81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,12 @@ dev = [ "pytest-mock", "pytest-watcher", "pytest-xdist", + # MCP adapter — the optional `mcp` extra, included here so the gate + # type-checks (mypy) and exercises the fastmcp adapter + its tests + # instead of silently skipping them. + "fastmcp", + # Scripts (scripts/mcp_swap.py dev tool) + "tomlkit", # Coverage "codecov", "coverage", @@ -72,6 +78,7 @@ dev = [ # Lint "ruff", "mypy", + "ty", ] docs = [ @@ -87,6 +94,10 @@ testing = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + # MCP adapter (optional `mcp` extra) so the adapter tests run here too + "fastmcp", + # Scripts (scripts/mcp_swap.py dev tool) + "tomlkit", ] coverage =[ "codecov", @@ -102,6 +113,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 +161,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 @@ -236,7 +325,10 @@ addopts = [ "--showlocals", "--doctest-docutils-modules", "-p no:doctest", - "--reruns=2" + "--reruns=2", + # Built HTML lives under docs/_build and `docs` is a testpath; never + # collect generated artifacts (their relative directives fail to parse). + "--ignore=docs/_build", ] doctest_optionflags = [ "ELLIPSIS", diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py new file mode 100644 index 000000000..db487ddad --- /dev/null +++ b/scripts/mcp_swap.py @@ -0,0 +1,1112 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = ["tomlkit>=0.13"] +# /// +"""Swap MCP server configs across Claude / Codex / Cursor / Gemini / Grok / Antigravity. + +Use when you want every installed agent CLI to run a local checkout of an +MCP server (editable) instead of a pinned release. ``use-local`` rewrites +each CLI's config to invoke the checkout via ``uv --directory run +``; ``revert`` restores from the timestamped backup the swap wrote. + +Defaults are derived from the current repo's ``pyproject.toml``: + +- entry command = first key of ``[project.scripts]`` +- server name = that entry with a trailing ``-mcp`` stripped + (``libtmux-engine-mcp`` -> ``libtmux-engine``), falling back to + ``project.name`` when the entry has no ``-mcp`` suffix. Deriving the + slug from the entry (not ``project.name``) keeps this repo's server + key distinct from a sibling package whose ``project.name`` differs + from its console-script name. + +Examples +-------- +```console +$ uv run scripts/mcp_swap.py detect +$ uv run scripts/mcp_swap.py status +$ uv run scripts/mcp_swap.py use-local --dry-run +$ uv run scripts/mcp_swap.py use-local +$ uv run scripts/mcp_swap.py revert +``` + +Scope +----- +This script is best-effort and intentionally narrow: + +- **Global configs only.** Writes to ``~/.cursor/mcp.json``, + ``~/.claude.json``, ``~/.codex/config.toml``, + ``~/.gemini/settings.json``, ``~/.grok/config.toml`` (TOML + ``mcp_servers``, same shape as Codex), and + ``~/.gemini/antigravity/mcp_config.json`` (Antigravity, JSON + ``mcpServers``). The Antigravity desktop IDE and the ``agy`` CLI may + read different profiles; only the documented profile path above is + written. Workspace / project-local configs + (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``, + per-project ``projects..mcpServers`` entries inside + ``~/.claude.json`` *are* recognised for Claude only) are NOT + walked — workspace files for Cursor/Gemini are silently ignored. + When workspace precedence matters, run the CLI's own + ``cursor mcp add ...`` / ``gemini mcp add ...`` directly. + +- **Claude scope.** ``use-local`` and ``revert`` accept + ``--scope {user,project}``. The default ``project`` writes the + per-project entry under ``projects[].mcpServers`` — + only the current repo's directory sees the swap, matching + pre-flag behaviour. ``--scope user`` writes Claude's top-level + ``mcpServers`` fallback so every project that has no per-project + override picks up the swap; useful when QA-ing a branch across + many directories. Codex, Cursor, and Gemini have no per-project + layer in their config files; the flag is silently coerced to + ``user`` for them. Both Claude scopes can coexist with + independent backups; full ``revert`` unwinds in LIFO order. +- **Simple binary detection.** Probing is ``shutil.which()`` + plus ``.exists()``. Custom install locations + (Homebrew, npm prefixes, ``~/.npm-global/bin``, + ``~/.claude/local/claude``, ``~/.gemini/local/gemini``) are picked + up only if the binary is on ``PATH``. FastMCP's installer probes + these locations directly; this script does not. +- **Single config shape per CLI.** No fallback paths, no merge of + multiple sources. If your setup deviates from the defaults above, + use the CLI's native ``mcp`` subcommand instead. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import difflib +import json +import os +import pathlib +import shutil +import sys +import tempfile +import time +import typing as t + +import tomlkit +import tomlkit.items + +CLIName = t.Literal["claude", "codex", "cursor", "gemini", "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. Codex / Cursor / Gemini have no +#: per-project scope in their config files, so for those CLIs the scope +#: is always normalised to ``"user"`` regardless of what was passed. +Scope = t.Literal["user", "project"] +ALL_SCOPES: tuple[Scope, ...] = ("user", "project") + + +def _normalize_scope(cli: CLIName, scope: Scope | None) -> Scope: + """Coerce ``scope`` to the value that actually applies to ``cli``. + + Non-Claude CLIs have no per-project config layer — every write to + them is necessarily user-level — so the flag is silently coerced to + ``"user"`` for those. For Claude, ``None`` defaults to ``"project"`` + to preserve pre-flag behaviour where the script always wrote the + per-project entry. + """ + if cli != "claude": + return "user" + return scope if scope is not None else "project" + + +def _state_key(cli: CLIName, scope: Scope) -> str: + """Compose the ``cli:scope`` key used inside the state file.""" + return f"{cli}:{scope}" + + +def _parse_state_key(key: str) -> tuple[CLIName, Scope] | None: + """Decode a ``cli:scope`` state key, returning ``None`` for malformed input. + + The script declares no compatibility contract for its state file — + schema is internal — so this only accepts the canonical + ``f"{cli}:{scope}"`` form. Hand-edited or unrecognised keys return + ``None`` so ``load_state`` can drop them without crashing. + """ + if ":" not in key: + return None + cli_str, _, scope_str = key.partition(":") + if cli_str in ALL_CLIS and scope_str in ALL_SCOPES: + return cli_str, scope_str + return None + + +def _parse_state_entry(v: dict[str, t.Any]) -> SwapEntry | None: + """Build a :class:`SwapEntry` from a raw state-file dict, or ``None``. + + Validates at the trust boundary so a hand-edited ``state.json`` can't + crash later code paths — particularly :func:`cmd_revert`'s LIFO sort, + which compares ``SwapEntry.seq_no`` and would raise ``TypeError`` on a + mixed ``int``/``str`` ordering. ``seq_no`` is coerced via ``int()``; + any ``KeyError`` (missing required field), ``ValueError`` (non-numeric + string), or ``TypeError`` (wrong shape, extra keys for the dataclass) + drops the entry silently. Same drop-on-malformed posture as + :func:`_parse_state_key`. + + Mirrors CPython's ``Lib/sched.py`` discipline: validate at the + counter's *origin* (``enterabs`` for sched, ``load_state`` here), not + at sort time. State-file schema is internal — no compatibility + contract — so silent drop is the right failure mode. + """ + try: + v = {**v, "seq_no": int(v["seq_no"])} + return SwapEntry(**v) + except (KeyError, TypeError, ValueError): + return None + + +def _xdg_state_home() -> pathlib.Path: + """Resolve ``$XDG_STATE_HOME`` per the XDG Base Directory spec. + + Defaults to ``~/.local/state`` when the env var is unset or empty. + State is the right XDG bucket here (vs. cache / config / data): the + file is machine-written, must persist across runs so ``revert`` can + locate the right backup, but is not safely deletable like cache nor + user-edited like config. + """ + env = os.environ.get("XDG_STATE_HOME") + if env: + return pathlib.Path(env) + return pathlib.Path.home() / ".local" / "state" + + +# ``-dev`` suffix in the namespace makes it loud that this is dev-only +# tooling state, distinct from the runtime ``libtmux`` package and from +# any sibling ``libtmux-mcp-dev`` swap state. +STATE_DIR = _xdg_state_home() / "libtmux-engine-mcp-dev" / "swap" +STATE_FILE = STATE_DIR / "state.json" + +BACKUP_SUFFIX_PREFIX = ".bak.mcp-swap-" + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True) +class CLIInfo: + """Static descriptor for a CLI's config file and discovery heuristics.""" + + name: CLIName + binary: str + config_path: pathlib.Path + fmt: t.Literal["json", "toml"] + + +CLIS: dict[CLIName, CLIInfo] = { + "claude": CLIInfo( + name="claude", + binary="claude", + config_path=pathlib.Path.home() / ".claude.json", + fmt="json", + ), + "codex": CLIInfo( + name="codex", + binary="codex", + config_path=pathlib.Path.home() / ".codex" / "config.toml", + fmt="toml", + ), + "cursor": CLIInfo( + name="cursor", + binary="cursor-agent", + config_path=pathlib.Path.home() / ".cursor" / "mcp.json", + fmt="json", + ), + "gemini": CLIInfo( + name="gemini", + binary="gemini", + config_path=pathlib.Path.home() / ".gemini" / "settings.json", + fmt="json", + ), + "grok": CLIInfo( + name="grok", + binary="grok", + config_path=pathlib.Path.home() / ".grok" / "config.toml", + fmt="toml", + ), + # Antigravity (the ``agy`` CLI). Its MCP config is the standard JSON + # ``mcpServers`` shape (same as Cursor / Gemini) under the + # Antigravity profile dir. The file may not exist until the IDE/CLI + # writes it and starts empty; ``load_config`` tolerates a 0-byte + # JSON file as ``{}``. Note: the desktop IDE and the ``agy`` CLI may + # read different profiles; this targets the documented profile path. + "agy": CLIInfo( + name="agy", + binary="agy", + config_path=pathlib.Path.home() / ".gemini" / "antigravity" / "mcp_config.json", + fmt="json", + ), +} + + +@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. + + An empty JSON file is treated as an empty object ``{}`` rather than a + parse error: Antigravity's ``mcp_config.json`` is created empty until + a server is added, so a swap must be able to seed the first entry. + """ + raw = info.config_path.read_bytes() + if info.fmt == "json": + 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 + + +# --------------------------------------------------------------------------- +# 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) + ) + command = str(entry.get("command", "")) + raw_args = entry.get("args", []) + args = [str(a) for a in raw_args] if raw_args else [] + raw_env = entry.get("env") or {} + env = {str(k): str(v) for k, v in dict(raw_env).items()} + return McpServerSpec(command=command, args=args, env=env) + + +# --------------------------------------------------------------------------- +# Repo metadata +# --------------------------------------------------------------------------- + + +def resolve_repo_meta(repo: pathlib.Path) -> tuple[str, str]: + """Derive (server_name, entry_command) from the repo's pyproject.toml. + + The server name is the registration slug used as the config-file key + (``mcpServers.`` in JSON, ``[mcp_servers.]`` in TOML). + Default: the first ``[project.scripts]`` entry with a trailing + ``-mcp`` stripped (``libtmux-engine-mcp`` → ``libtmux-engine``), + falling back to ``project.name`` when the entry has no ``-mcp`` + suffix. Deriving the slug from the entry rather than ``project.name`` + keeps this repo's server key (``libtmux-engine``) distinct from a + sibling package whose ``project.name`` is ``libtmux`` — both can be + registered side by side. Pass ``--server `` to override. + """ + pyproject = repo / "pyproject.toml" + doc = tomlkit.parse(pyproject.read_text()) + project = doc.get("project") + if project is None: + msg = f"{pyproject} has no [project] table" + raise RuntimeError(msg) + scripts = project.get("scripts") or {} + if not scripts: + msg = f"{pyproject} has no [project.scripts] — cannot derive entry" + raise RuntimeError(msg) + entry = next(iter(scripts)) + server = entry[: -len("-mcp")] if entry.endswith("-mcp") else str(project["name"]) + return server, entry + + +def build_local_spec(repo: pathlib.Path, entry: str) -> McpServerSpec: + """Build the ``uv --directory run `` spec used by ``use-local``.""" + return McpServerSpec( + command="uv", + args=["--directory", str(repo.resolve()), "run", entry], + ) + + +# --------------------------------------------------------------------------- +# State file +# --------------------------------------------------------------------------- + + +def load_state() -> dict[tuple[CLIName, Scope], SwapEntry]: + """Read the swap-state file, returning an empty mapping when absent. + + The state file's schema is internal — no compatibility contract — + so this loader assumes a single canonical shape. Malformed keys + (those that don't parse as ``cli:scope``) and entries with a + non-coercible ``seq_no`` or missing required fields are dropped + silently so a hand-edited file cannot crash the script. + """ + if not STATE_FILE.exists(): + return {} + raw = json.loads(STATE_FILE.read_text()) + entries = raw.get("entries", {}) + out: dict[tuple[CLIName, Scope], SwapEntry] = {} + for k, v in entries.items(): + parsed = _parse_state_key(k) + if parsed is None: + continue + entry = _parse_state_entry(v) + if entry is None: + continue + out[parsed] = entry + return out + + +def save_state(entries: dict[tuple[CLIName, Scope], SwapEntry]) -> None: + """Write the swap-state file atomically.""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "entries": { + _state_key(cli, scope): dataclasses.asdict(v) + for (cli, scope), v in entries.items() + }, + } + atomic_write(STATE_FILE, (json.dumps(payload, indent=2) + "\n").encode("utf-8")) + + +def clear_state(keys: t.Iterable[tuple[CLIName, Scope]]) -> None: + """Remove the given ``(cli, scope)`` keys; delete the file if empty.""" + current = load_state() + for key in keys: + current.pop(key, None) + if current: + save_state(current) + elif STATE_FILE.exists(): + STATE_FILE.unlink() + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class Presence: + """Detection outcome for a CLI: binary on PATH and config file present.""" + + cli: CLIName + binary_found: bool + config_found: bool + + @property + def present(self) -> bool: + """Return True only when both the binary and the config file were found.""" + return self.binary_found and self.config_found + + +def detect_clis() -> list[Presence]: + """Probe all supported CLIs and return their detection results.""" + return [ + Presence( + cli=info.name, + binary_found=shutil.which(info.binary) is not None, + config_found=info.config_path.exists(), + ) + for info in CLIS.values() + ] + + +def present_clis() -> list[CLIName]: + """Return the list of CLIs that have both a binary and a config present.""" + return [p.cli for p in detect_clis() if p.present] + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +def cmd_detect(args: argparse.Namespace) -> int: + """Print detection results for every supported CLI.""" + for p in detect_clis(): + flag = "yes" if p.present else " no" + extra = [] + if not p.binary_found: + extra.append("binary missing") + if not p.config_found: + extra.append(f"config missing: {CLIS[p.cli].config_path}") + suffix = f" ({', '.join(extra)})" if extra else "" + print(f" [{flag}] {p.cli:<7}{suffix}") + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + """Print the current MCP server entry per detected CLI. + + For Claude, prints separate lines for the user-level fallback + (``[claude:user]``) and the per-project override + (``[claude:project]``) when both exist; if only one exists, only + that line shows. ``args.scope`` (when set) restricts Claude output + to the matching layer only. Other CLIs print a single line as + ``[]`` since their config has no scope concept and ignore + ``args.scope``. + """ + repo = pathlib.Path(args.repo).resolve() + server = args.server or resolve_repo_meta(repo)[0] + scope_filter: Scope | None = args.scope + for cli in args.cli or present_clis(): + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{cli}] (no config at {info.config_path})") + continue + # Wrap the read + shape-guarded queries in try/except RuntimeError + # so a malformed Claude config surfaces as a clean per-CLI error + # instead of aborting status output for the rest of the CLIs. + try: + config = load_config(info) + if cli == "claude": + # Lazy reads: skip the get_server call entirely for the + # filtered-out scope so a malformed projects node doesn't + # raise when the user only asked about user scope. + user_spec = ( + get_server(cli, config, server, repo, scope="user") + if scope_filter in (None, "user") + else None + ) + project_spec = ( + get_server(cli, config, server, repo, scope="project") + if scope_filter in (None, "project") + else None + ) + shown = False + if user_spec is not None: + tag = _describe_spec(user_spec, repo) + print( + f"[claude:user] {server} = {user_spec.command} " + f"{' '.join(user_spec.args)} ({tag})" + ) + shown = True + if project_spec is not None: + tag = _describe_spec(project_spec, repo) + print( + f"[claude:project] {server} = {project_spec.command} " + f"{' '.join(project_spec.args)} ({tag})" + ) + shown = True + if not shown: + label = f"claude:{scope_filter}" if scope_filter else "claude" + print(f"[{label}] no entry for {server!r}") + else: + spec = get_server(cli, config, server, repo) + if spec is None: + print(f"[{cli}] no entry for {server!r}") + continue + tag = _describe_spec(spec, repo) + print( + f"[{cli}] {server} = {spec.command} {' '.join(spec.args)} ({tag})" + ) + except RuntimeError as exc: + print(f"[{cli}] {exc}", file=sys.stderr) + continue + return 0 + + +def _describe_spec(spec: McpServerSpec, repo: pathlib.Path) -> str: + """Return a short label classifying a spec (local/pypi-pin/other).""" + if spec.is_local_uv_directory(): + local = spec.local_repo_path() + if local and local.resolve() == repo.resolve(): + return "local: this repo" + return f"local: {local}" + if spec.command == "uvx": + pinned = next((a for a in spec.args if "==" in a or "@" in a), None) + return f"pypi pin: {pinned}" if pinned else "pypi (unpinned)" + return "other" + + +def cmd_use_local(args: argparse.Namespace) -> int: + """Rewrite each target CLI's config to run the repo's checkout via ``uv``. + + The optional ``--scope`` flag selects Claude's user-level fallback + vs. per-project override; see :data:`Scope`. The flag is silently + coerced to ``"user"`` for non-Claude CLIs by :func:`_normalize_scope`. + """ + repo = pathlib.Path(args.repo).resolve() + server, default_entry = resolve_repo_meta(repo) + server = args.server or server + entry = args.entry or default_entry + spec = build_local_spec(repo, entry) + + targets = args.cli or present_clis() + if not targets: + print("no CLIs detected — nothing to do", file=sys.stderr) + return 1 + + ts = time.strftime("%Y%m%d%H%M%S") + state = load_state() + had_error = 0 + for cli in targets: + scope = _normalize_scope(cli, args.scope) + label = f"{cli}:{scope}" if cli == "claude" else cli + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{label}] skip — config not found at {info.config_path}") + continue + # Wrap the read + shape-guarded mutation in try/except RuntimeError + # so a malformed Claude config (top-level mcpServers / projects not a + # mapping) surfaces as a clean per-CLI error instead of an uncaught + # traceback. Same per-CLI continuation pattern the inner write-failure + # handler below uses. + try: + original_bytes = info.config_path.read_bytes() + config = load_config(info) + current = get_server(cli, config, server, repo, scope=scope) + if ( + current + and current.is_local_uv_directory() + and current.local_repo_path() == repo + ): + print(f"[{label}] already local (this repo) — no change") + continue + # Preserve the existing entry's env on replacement. ``build_local_spec`` + # writes an empty env, so without this merge a swap would silently drop + # client-side settings (LIBTMUX_SAFETY, LIBTMUX_SOCKET, custom dev + # knobs). Symmetric with ``_spec_from_entry`` which round-trips env on + # the read side. + cli_spec = ( + dataclasses.replace(spec, env={**current.env}) if current else spec + ) + action = set_server(cli, config, server, cli_spec, repo, scope=scope) + new_bytes = dump_config_bytes(info, config) + except RuntimeError as exc: + print(f"[{label}] {exc}", file=sys.stderr) + had_error = 1 + continue + + if args.dry_run: + print(f"--- {info.config_path} (current)") + print(f"+++ {info.config_path} (proposed)") + diff = difflib.unified_diff( + original_bytes.decode(errors="replace").splitlines(keepends=True), + new_bytes.decode(errors="replace").splitlines(keepends=True), + lineterm="", + ) + sys.stdout.writelines(diff) + continue + + # Claude is the only CLI where two swaps (different scopes) can + # touch the same config file in one second; embed the scope so + # the second backup doesn't overwrite the first. Non-Claude + # backup filenames carry no scope suffix. + backup_suffix = f"{BACKUP_SUFFIX_PREFIX}{ts}" + if cli == "claude": + backup_suffix += f"-{scope}" + backup_path = info.config_path.with_suffix( + info.config_path.suffix + backup_suffix + ) + backup_path.write_bytes(original_bytes) + try: + atomic_write(info.config_path, new_bytes) + _revalidate(info) + except Exception as exc: + atomic_write(info.config_path, original_bytes) + print( + f"[{label}] write failed ({exc}); backup at {backup_path}", + file=sys.stderr, + ) + had_error = 1 + continue + next_seq = max((e.seq_no for e in state.values()), default=-1) + 1 + state[(cli, scope)] = SwapEntry( + config_path=str(info.config_path), + backup_path=str(backup_path), + server=server, + action=action, + swapped_at=ts, + seq_no=next_seq, + ) + print(f"[{label}] {action}; backup: {backup_path}") + + if not args.dry_run: + save_state(state) + return had_error + + +def _revalidate(info: CLIInfo) -> None: + """Re-parse the file after writing; raise on failure.""" + load_config(info) + + +def cmd_revert(args: argparse.Namespace) -> int: + """Restore each target CLI's config from the backup recorded in the state file. + + Without ``--scope``, every recorded entry for the targeted CLIs is + reverted (so a Claude install that has both user-scope and + project-scope swaps gets both restored). With ``--scope``, only + the matching scope is reverted; the parameter is silently coerced + to ``"user"`` for non-Claude CLIs. + """ + state = load_state() + # Without --cli, revert every CLI that has any recorded swap. + targets = list(args.cli) if args.cli else list({cli for cli, _scope in state}) + if not targets: + print("no recorded swaps — nothing to revert", file=sys.stderr) + return 1 + + reverted: list[tuple[CLIName, Scope]] = [] + for cli in targets: + if args.scope is not None: + wanted_scopes: tuple[Scope, ...] = (_normalize_scope(cli, args.scope),) + else: + wanted_scopes = ALL_SCOPES + cli_keys = [ + (sc_cli, sc_scope) + for (sc_cli, sc_scope) in state + if sc_cli == cli and sc_scope in wanted_scopes + ] + if not cli_keys: + label = f"{cli}:{args.scope}" if args.scope and cli == "claude" else cli + print(f"[{label}] no state entry — skip") + continue + # Unwind in reverse-registration order (LIFO) — sort by the + # explicit ``SwapEntry.seq_no`` counter so order is independent + # of JSON parse order, dict iteration, and wall-clock + # collisions. ``seq_no`` is coerced to ``int`` at load time by + # ``_parse_state_entry``; entries with a non-coercible value + # are dropped before they reach this sort, so the comparison + # is always int vs int. When two scopes back the same physical + # file (Claude user + project), the later swap's backup + # contains the earlier swap's modifications, so each backup + # must restore its own layer before the prior one is restored. + # Same explicit counter pattern CPython's ``Lib/sched.py`` uses + # to break ties on ``Event(time, priority, sequence, …)``. + cli_keys.sort(key=lambda k: state[k].seq_no, reverse=True) + for key in cli_keys: + sc_cli, sc_scope = key + entry = state[key] + label = f"{sc_cli}:{sc_scope}" if sc_cli == "claude" else sc_cli + backup = pathlib.Path(entry.backup_path) + dest = pathlib.Path(entry.config_path) + if not backup.exists(): + print(f"[{label}] backup missing: {backup}", file=sys.stderr) + continue + if args.dry_run: + print(f"[{label}] would restore {dest} from {backup}") + continue + atomic_write(dest, backup.read_bytes()) + # Backup served its purpose; LIFO unwind for this layer is + # complete. Delete on success, keep on error — same idiom + # CPython's ``tempfile.NamedTemporaryFile`` uses + # (Lib/tempfile.py:614-618). If ``atomic_write`` had raised, + # this line wouldn't run and the backup would survive for + # post-mortem; on success the backup is redundant and would + # otherwise accumulate forever across swap/revert cycles. + backup.unlink() + print(f"[{label}] restored from {backup}") + reverted.append(key) + + if not args.dry_run and reverted: + clear_state(reverted) + return 0 + + +# --------------------------------------------------------------------------- +# argparse glue +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + """Construct the ``argparse`` parser for ``mcp_swap``.""" + p = argparse.ArgumentParser(prog="mcp_swap", description=__doc__.splitlines()[0]) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser( + "detect", help="list installed CLIs and their config presence" + ).set_defaults(func=cmd_detect) + + ps = sub.add_parser("status", help="show the current MCP server entry per CLI") + ps.add_argument("--repo", default=".", help="repo root (default: .)") + ps.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + ps.add_argument( + "--cli", action="append", choices=ALL_CLIS, help="limit to one or more CLIs" + ) + ps.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Limit Claude output to one scope: 'user' shows only the " + "top-level mcpServers fallback, 'project' shows only the " + "projects..mcpServers entry. Without this flag, both " + "Claude scopes print when both have an entry. No-op for " + "non-Claude CLIs (their config has no per-project layer)." + ), + ) + ps.set_defaults(func=cmd_status) + + pu = sub.add_parser("use-local", help="rewrite configs to run this checkout") + pu.add_argument("--repo", default=".", help="repo root (default: .)") + pu.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + pu.add_argument( + "--entry", help="uv run entry command (default: [project.scripts] first key)" + ) + pu.add_argument("--cli", action="append", choices=ALL_CLIS) + pu.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Claude config scope: 'user' rewrites the top-level mcpServers " + "fallback (every project without an override picks it up), " + "'project' rewrites projects..mcpServers under this repo. " + "Default 'project'. Silently coerced to 'user' for non-Claude CLIs." + ), + ) + pu.add_argument("--dry-run", action="store_true") + pu.set_defaults(func=cmd_use_local) + + pr = sub.add_parser("revert", help="restore each CLI's config from its swap backup") + pr.add_argument("--cli", action="append", choices=ALL_CLIS) + pr.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Limit revert to one Claude scope. Without this flag, every " + "recorded scope for the targeted CLIs is reverted." + ), + ) + pr.add_argument("--dry-run", action="store_true") + pr.set_defaults(func=cmd_revert) + + return p + + +def main(argv: list[str] | None = None) -> int: + """Entry point — dispatches to the selected subcommand.""" + args = build_parser().parse_args(argv) + return t.cast("int", args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/libtmux/experimental/__init__.py b/src/libtmux/experimental/__init__.py new file mode 100644 index 000000000..1bcc80a96 --- /dev/null +++ b/src/libtmux/experimental/__init__.py @@ -0,0 +1,19 @@ +"""Experimental libtmux APIs. + +This package hosts work that is **not** covered by the project's versioning +policy. Anything under :mod:`libtmux.experimental` may change shape or be +removed between any two releases without notice. + +Current contents: + +- :mod:`libtmux.experimental.ops` -- inert, typed tmux *operation* values: the + pure source of truth that renders tmux commands, carries result types, and + serializes without a live tmux server. +- :mod:`libtmux.experimental.engines` -- *engine* protocols and + implementations that execute operations and return typed results. + +See the operationalization plan (``tmux-python/libtmux`` issue 689) and the +architecture proposal (issue 688) for background. +""" + +from __future__ import annotations diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py new file mode 100644 index 000000000..9b756af4c --- /dev/null +++ b/src/libtmux/experimental/engines/__init__.py @@ -0,0 +1,62 @@ +"""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 (concrete), a persistent +``tmux -C`` control connection, an async transport, or (as an easter egg) tmux's +native binary peer protocol -- and return the *same* typed result. + +See the operationalization plan (``tmux-python/libtmux`` issue 689). +""" + +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, + TmuxEngine, +) +from libtmux.experimental.engines.concrete import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines.control_mode import ( + ControlModeEngine, + ControlModeError, + ControlModeParser, +) +from libtmux.experimental.engines.imsg import ImsgEngine +from libtmux.experimental.engines.registry import ( + available_engines, + create_engine, + register_engine, +) +from libtmux.experimental.engines.subprocess import SubprocessEngine + +__all__ = ( + "AsyncConcreteEngine", + "AsyncControlModeEngine", + "AsyncSubprocessEngine", + "AsyncTmuxEngine", + "CommandRequest", + "CommandResult", + "ConcreteEngine", + "ControlModeEngine", + "ControlModeError", + "ControlModeParser", + "ControlNotification", + "EngineKind", + "EngineSpec", + "ImsgEngine", + "SubprocessEngine", + "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..fbd0fd1a5 --- /dev/null +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -0,0 +1,413 @@ +"""An asynchronous control-mode (``tmux -C``) engine with an event stream. + +A real async control engine -- not an ``asyncio.to_thread`` wrapper around the +sync one. It holds a persistent ``tmux -C`` connection, reads it from a single +background task, correlates each command to an :class:`asyncio.Future`, and +exposes tmux's asynchronous notifications (``%output``, ``%window-add``, ...) as +an ``async for`` event stream. + +Design, informed by prior libtmux/mux control-mode work: + +- The I/O-free :class:`~.control_mode.ControlModeParser` is reused verbatim; only + the I/O layer differs from the sync engine (``await stdout.read`` instead of + ``selectors``). +- Command correlation is a FIFO of futures resolved in block-arrival order. A + block that arrives with *no* pending command is **unsolicited** (a hook- + triggered command, or the startup ACK) and is skipped, so correlation never + desyncs. The startup ACK is consumed synchronously in :meth:`start` before the + reader launches, closing the startup race. +- A reader failure or EOF marks the engine *dead* and fails every pending + command, rather than hanging. +- Notifications go to a bounded queue; on overflow the oldest is dropped and + counted (backpressure), mirroring control mode's own ``%pause`` philosophy. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import shutil +import typing as t +from dataclasses import dataclass, field + +from libtmux import exc +from libtmux.experimental.engines.base import render_control_line +from libtmux.experimental.engines.control_mode import ( + ControlModeError, + ControlModeParser, + _merge_blocks, + command_count, +) + +if t.TYPE_CHECKING: + import types + from collections.abc import AsyncIterator, Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + from libtmux.experimental.engines.control_mode import ControlModeBlock + +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_STARTUP_TIMEOUT = 5.0 +_STOP_TIMEOUT = 2.0 + + +@dataclass(frozen=True) +class ControlNotification: + """An asynchronous tmux control-mode notification. + + Examples + -------- + >>> ControlNotification.parse(b"%window-add @3") + ControlNotification(kind='window-add', args=('@3',), raw='%window-add @3') + >>> ControlNotification.parse(b"%output %1 hello world").kind + 'output' + """ + + kind: str + args: tuple[str, ...] + raw: str + + @classmethod + def parse(cls, line: bytes) -> ControlNotification: + """Parse a raw ``%``-notification line.""" + text = line.decode(errors="replace") + body = text[1:] if text.startswith("%") else text + parts = body.split(" ") + kind = parts[0] if parts else "" + return cls(kind=kind, args=tuple(parts[1:]), raw=text) + + +@dataclass(slots=True) +class _PendingCommand: + future: asyncio.Future[CommandResult] + argv: tuple[str, ...] + 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 + + +class AsyncControlModeEngine: + """Execute tmux commands over one persistent async ``tmux -C`` connection. + + Parameters + ---------- + tmux_bin : str or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before ``-C``. + timeout : float + Seconds to await a command's result before failing it. + event_queue_size : int + Bounded size of the notification queue (backpressure). + + Notes + ----- + The connection opens lazily on first use. Use the engine as an async context + manager, or call :meth:`aclose`, to tear it down. + """ + + def __init__( + self, + tmux_bin: str | None = None, + *, + server_args: Sequence[str] = (), + timeout: float = _DEFAULT_TIMEOUT, + event_queue_size: int = 4096, + ) -> None: + self.tmux_bin = tmux_bin + self.server_args = tuple(server_args) + self.timeout = timeout + self._parser = ControlModeParser() + self._pending: collections.deque[_PendingCommand] = collections.deque() + self._event_queue_size = event_queue_size + self._subscribers: set[asyncio.Queue[ControlNotification]] = set() + self._dropped_notifications = 0 + self._proc: asyncio.subprocess.Process | None = None + self._reader_task: asyncio.Task[None] | None = None + self._start_lock = asyncio.Lock() + self._write_lock = asyncio.Lock() + self._started = False + self._dead: BaseException | None = None + + async def start(self) -> None: + """Spawn ``tmux -C``, consume the startup ACK, and start the reader.""" + async with self._start_lock: + if self._started: + return + tmux_bin = self.tmux_bin or shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + cmd = [tmux_bin, *self.server_args, "-C"] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + self._proc = proc + self._dead = None + await self._consume_startup() + self._reader_task = asyncio.create_task( + self._reader(), + name="libtmux-async-control-reader", + ) + self._started = True + + async def _consume_startup(self) -> None: + """Read and discard tmux's startup ACK block before commands flow. + + Doing this synchronously (before the reader task launches and before any + command future is queued) means the startup block can never be matched + to a real command. + """ + proc = self._proc + if proc is None or proc.stdout is None: + return + loop = asyncio.get_running_loop() + deadline = loop.time() + _STARTUP_TIMEOUT + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return + try: + chunk = await asyncio.wait_for( + proc.stdout.read(_READ_CHUNK), + timeout=remaining, + ) + except asyncio.TimeoutError: + return + if not chunk: + return + self._parser.feed(chunk) + self._parser.notifications() # discard any startup notifications + if self._parser.blocks(): # startup ACK seen and discarded + return + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command over the control connection.""" + return (await self.run_batch([request]))[0] + + async def run_batch( + self, requests: Sequence[CommandRequest] + ) -> list[CommandResult]: + """Pipeline a batch of commands; one result per request, in order.""" + if not requests: + return [] + await self.start() + if self._dead is not None: + msg = "control-mode engine is dead" + raise ControlModeError(msg) from self._dead + + loop = asyncio.get_running_loop() + rendered = [tuple(req.args) for req in requests] + futures: list[asyncio.Future[CommandResult]] = [] + async with self._write_lock: + proc = self._proc + if proc is None or proc.stdin is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + 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 (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.set_exception(cm_error) + 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 + + 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. + """ + queue: asyncio.Queue[ControlNotification] = asyncio.Queue( + maxsize=self._event_queue_size, + ) + self._subscribers.add(queue) + try: + while True: + yield await queue.get() + finally: + self._subscribers.discard(queue) + + @property + def dropped_notifications(self) -> int: + """How many notifications were dropped due to a full event queue.""" + return self._dropped_notifications + + async def aclose(self) -> None: + """Tear down the connection: cancel the reader, fail pending, kill proc.""" + if not self._started: + return + self._started = False + reader = self._reader_task + self._reader_task = None + if reader is not None: + reader.cancel() + with contextlib.suppress(asyncio.CancelledError): + await reader + self._fail_pending(ControlModeError("control-mode engine closed")) + proc = self._proc + self._proc = None + if proc is not None and proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=_STOP_TIMEOUT) + except asyncio.TimeoutError: + with contextlib.suppress(ProcessLookupError): + proc.kill() + await proc.wait() + + async def __aenter__(self) -> AsyncControlModeEngine: + """Start the engine on context entry.""" + await self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Close the engine on context exit.""" + await self.aclose() + + async def _reader(self) -> None: + """Background task: read tmux output, resolve futures, publish events.""" + proc = self._proc + if proc is None or proc.stdout is None: + return + stdout = proc.stdout + try: + while True: + chunk = await stdout.read(_READ_CHUNK) + if not chunk: + self._mark_dead(ControlModeError("tmux -C closed stdout")) + return + self._parser.feed(chunk) + for block in self._parser.blocks(): + self._dispatch_block(block) + for line in self._parser.notifications(): + self._publish(line) + except asyncio.CancelledError: + raise + except Exception as error: + self._mark_dead(ControlModeError(f"control-mode reader failed: {error}")) + + def _dispatch_block(self, block: ControlModeBlock) -> None: + """Accumulate a solicited block; resolve the command once it has them all. + + A ``;``-folded command emits one block per sub-command; unsolicited blocks + (hook-triggered commands, the startup ACK) carry flags 0 and are skipped, + so FIFO correlation never desyncs. + """ + if block.flags != 1: + return # unsolicited (hook-triggered command or startup ACK): skip + if not self._pending: + return + pending = self._pending[0] + pending.blocks.append(block) + if len(pending.blocks) < pending.expected: + return + self._pending.popleft() + if not pending.future.done(): + pending.future.set_result(_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 _mark_dead(self, error: BaseException) -> None: + """Record the engine as dead and fail all pending commands.""" + if self._dead is None: + self._dead = error + self._fail_pending(error) + + def _fail_pending(self, error: BaseException) -> None: + """Fail every queued command future with *error*.""" + while self._pending: + pending = self._pending.popleft() + if not pending.future.done(): + pending.future.set_exception(error) + + @classmethod + def for_server(cls, server: t.Any, **kwargs: t.Any) -> AsyncControlModeEngine: + """Build an async control-mode engine bound to a live server's socket.""" + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls( + tmux_bin=getattr(server, "tmux_bin", None), + server_args=server_args, + **kwargs, + ) diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py new file mode 100644 index 000000000..7ce87d0d8 --- /dev/null +++ b/src/libtmux/experimental/engines/asyncio.py @@ -0,0 +1,130 @@ +"""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 shutil +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class AsyncSubprocessEngine: + """Execute tmux commands via :func:`asyncio.create_subprocess_exec`. + + Parameters + ---------- + tmux_bin : str or pathlib.Path or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before the command. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import SendKeys, arun + >>> from libtmux.experimental.ops._types import PaneId + >>> engine = AsyncSubprocessEngine() + >>> hasattr(engine, "run") and hasattr(engine, "run_batch") + True + """ + + def __init__( + self, + tmux_bin: str | pathlib.Path | None = None, + *, + server_args: Sequence[str] = (), + ) -> None: + self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self.server_args = tuple(server_args) + self._resolved_bin: str | None = None + + def _resolve_bin(self) -> str: + """Return the tmux binary path, memoized for the engine instance.""" + if self.tmux_bin is not None: + return self.tmux_bin + if self._resolved_bin is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved_bin = resolved + return self._resolved_bin + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command asynchronously and return its result.""" + tmux_bin = request.tmux_bin or self._resolve_bin() + cmd = [tmux_bin, *self.server_args, *request.args] + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + + try: + stdout_bytes, stderr_bytes = await process.communicate() + except asyncio.CancelledError: + # 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.""" + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls(tmux_bin=getattr(server, "tmux_bin", None), server_args=server_args) diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py new file mode 100644 index 000000000..8516eb028 --- /dev/null +++ b/src/libtmux/experimental/engines/base.py @@ -0,0 +1,191 @@ +"""Core engine abstractions: requests, results, and the engine protocols. + +Adapted from the ``libtmux-protocol-engines`` prototype. A +:class:`CommandRequest` is a rendered tmux argv plus an optional binary path; a +:class:`CommandResult` is the structured outcome. :class:`TmuxEngine` and +:class:`AsyncTmuxEngine` are :class:`typing.Protocol` types, so any object with +the right methods is an engine -- including a live :class:`libtmux.Server` for +the classic case -- without inheriting a base class. +""" + +from __future__ import annotations + +import enum +import shlex +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + +def render_control_line(argv: Sequence[str]) -> str: + """Render a tmux argv as a control-mode (``tmux -C``) command line. + + Each token is quoted for the control parser, but a standalone ``;`` separator + is left bare so a folded ``a ; b`` chain dispatches as two commands instead of + one command with a literal ``';'`` argument. + + Examples + -------- + >>> render_control_line(("rename-window", "-t", "@1", "a b")) + "rename-window -t @1 'a b'" + >>> render_control_line(("rename-window", "a", ";", "kill-window", "@2")) + 'rename-window a ; kill-window @2' + """ + return " ".join(token if token == ";" else shlex.quote(token) for token in argv) + + +@dataclass(frozen=True) +class CommandRequest: + """A rendered tmux command, ready for an engine to execute. + + Parameters + ---------- + args : tuple[str, ...] + The tmux argv *after* the binary (e.g. ``("split-window", "-t", "%1")``). + tmux_bin : str or None + Override the tmux binary for this request; ``None`` lets the engine + decide. + + Examples + -------- + >>> CommandRequest.from_args("split-window", "-t", "%1") + CommandRequest(args=('split-window', '-t', '%1'), tmux_bin=None) + >>> CommandRequest.from_args("kill-window", "-t", 2).args + ('kill-window', '-t', '2') + """ + + args: tuple[str, ...] + tmux_bin: str | None = None + + @classmethod + def from_args( + cls, + *args: t.Any, + tmux_bin: str | pathlib.Path | None = None, + ) -> CommandRequest: + """Build a request from arbitrary tokens, stringifying each.""" + return cls( + args=tuple(map(str, args)), + tmux_bin=str(tmux_bin) if tmux_bin is not None else None, + ) + + +@dataclass(frozen=True) +class CommandResult: + """The structured outcome of executing a :class:`CommandRequest`. + + A tmux-side failure (``%error`` / nonzero exit) is *data* here -- it sets + ``returncode`` and ``stderr`` rather than raising. Only engine-broken + conditions (missing binary, lost connection, protocol desync) raise. + + Parameters + ---------- + cmd : tuple[str, ...] + The full argv that ran (including the tmux binary). + stdout, stderr : tuple[str, ...] + Captured output lines. + returncode : int + tmux exit code (``-1`` when unknown). + """ + + cmd: tuple[str, ...] + stdout: tuple[str, ...] = () + stderr: tuple[str, ...] = () + returncode: int = 0 + + +class EngineKind(str, enum.Enum): + """Named engine families.""" + + SUBPROCESS = "subprocess" + CONCRETE = "concrete" + CONTROL_MODE = "control_mode" + IMSG = "imsg" + + +@dataclass(frozen=True) +class EngineSpec: + """A typed, serializable selector for an engine family. + + Examples + -------- + >>> EngineSpec.subprocess().kind + + >>> EngineSpec.imsg(protocol_version=8).protocol_version + 8 + >>> EngineSpec.subprocess(protocol_version=8) + Traceback (most recent call last): + ... + ValueError: protocol_version is only valid for the imsg engine + """ + + kind: EngineKind + protocol_version: int | None = None + extra: t.Mapping[str, t.Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + """Normalize and validate the spec.""" + kind = EngineKind(self.kind) + if kind is not EngineKind.IMSG and self.protocol_version is not None: + msg = "protocol_version is only valid for the imsg engine" + raise ValueError(msg) + object.__setattr__(self, "kind", kind) + + @classmethod + def subprocess(cls, *, protocol_version: int | None = None) -> EngineSpec: + """Build a subprocess (classic) engine spec.""" + return cls(kind=EngineKind.SUBPROCESS, protocol_version=protocol_version) + + @classmethod + def concrete(cls) -> EngineSpec: + """Build a concrete (in-memory) engine spec.""" + return cls(kind=EngineKind.CONCRETE) + + @classmethod + def control_mode(cls) -> EngineSpec: + """Build a control-mode engine spec.""" + return cls(kind=EngineKind.CONTROL_MODE) + + @classmethod + def imsg(cls, *, protocol_version: int | None = None) -> EngineSpec: + """Build an imsg (native binary) engine spec.""" + return cls(kind=EngineKind.IMSG, protocol_version=protocol_version) + + +@t.runtime_checkable +class TmuxEngine(t.Protocol): + """A synchronous executor of tmux commands.""" + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command and return its structured result.""" + ... + + def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests in order, returning one result per request. + + Persistent-connection engines (control mode) override this to pipeline; + stateless engines implement it as a loop over :meth:`run`. + """ + ... + + +@t.runtime_checkable +class AsyncTmuxEngine(t.Protocol): + """An asynchronous executor of tmux commands.""" + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command and return its structured result.""" + ... + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests in order, returning one result per request.""" + ... diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py new file mode 100644 index 000000000..50774e431 --- /dev/null +++ b/src/libtmux/experimental/engines/concrete.py @@ -0,0 +1,136 @@ +"""Deterministic, in-memory engines for tests and docs (no tmux server). + +The concrete engines simulate just enough tmux behaviour to exercise the +operation contract offline: creation commands that ask for an id +(``-P -F '#{pane_id}'``) get a fabricated, monotonic id, ``capture-pane`` returns +canned lines, and everything else succeeds with empty output. A sync +(:class:`ConcreteEngine`) and async (:class:`AsyncConcreteEngine`) variant share +the same simulation, so the same operation returns the same typed result through +either, with no tmux required. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +def _fabricate(fmt: str, counters: dict[str, int]) -> str: + """Fabricate one id per ``#{..._id}`` token in *fmt*, in their order. + + A single-token format (e.g. ``#{pane_id}``) yields one id, preserving the + historical behaviour; a multi-token capture (e.g. ``new-session -F + '#{session_id} #{window_id} #{pane_id}'``) yields a space-joined id per token. + """ + found: list[tuple[int, str, str]] = [] + for key, sigil in (("session_id", "$"), ("window_id", "@"), ("pane_id", "%")): + index = fmt.find(f"#{{{key}}}") + if index != -1: + found.append((index, key, sigil)) + if not found: + return "?" + parts: list[str] = [] + for _index, key, sigil in sorted(found): + counters[key] += 1 + parts.append(f"{sigil}{counters[key]}") + return " ".join(parts) + + +def _simulate( + argv: tuple[str, ...], + counters: dict[str, int], + capture_lines: tuple[str, ...], +) -> CommandResult: + """Produce a deterministic result for a rendered tmux command.""" + if "-P" in argv and "-F" in argv: + fmt = argv[argv.index("-F") + 1] + return CommandResult( + cmd=("tmux", *argv), + stdout=(_fabricate(fmt, counters),), + returncode=0, + ) + if argv and argv[0] == "capture-pane": + return CommandResult(cmd=("tmux", *argv), stdout=capture_lines, returncode=0) + return CommandResult(cmd=("tmux", *argv), returncode=0) + + +def _new_counters() -> dict[str, int]: + """Return a fresh id-counter map.""" + return {"pane_id": 0, "window_id": 0, "session_id": 0} + + +class ConcreteEngine: + """Execute operations against an in-memory simulation (synchronous). + + Parameters + ---------- + capture_lines : Sequence[str] + Lines that ``capture-pane`` returns. + + Notes + ----- + The simulation is stateless -- it fabricates ids for ``-P -F`` creators and + returns canned ``capture-pane`` lines, but has no notion of which objects + exist, so queries like ``has-session`` always succeed (``HasSession.exists`` + is always ``True``). Use a live engine for those. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, CapturePane, run + >>> from libtmux.experimental.ops._types import WindowId, PaneId + >>> engine = ConcreteEngine(capture_lines=("hello", "world")) + >>> run(SplitWindow(target=WindowId("@1")), engine).new_pane_id + '%1' + >>> run(SplitWindow(target=WindowId("@1")), engine).new_pane_id + '%2' + >>> run(CapturePane(target=PaneId("%1")), engine).lines + ('hello', 'world') + """ + + def __init__(self, *, capture_lines: Sequence[str] = ()) -> None: + self.capture_lines = tuple(capture_lines) + self._counters = _new_counters() + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one request against the in-memory simulation.""" + return _simulate(request.args, self._counters, self.capture_lines) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order (no batching benefit).""" + return [self.run(req) for req in requests] + + +class AsyncConcreteEngine: + """Async sibling of :class:`ConcreteEngine` for offline async tests/docs. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import SplitWindow, arun + >>> from libtmux.experimental.ops._types import WindowId + >>> async def main(): + ... return await arun(SplitWindow(target=WindowId("@1")), AsyncConcreteEngine()) + >>> asyncio.run(main()).new_pane_id + '%1' + """ + + def __init__(self, *, capture_lines: Sequence[str] = ()) -> None: + self.capture_lines = tuple(capture_lines) + self._counters = _new_counters() + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one request against the in-memory simulation.""" + return _simulate(request.args, self._counters, self.capture_lines) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute each request in order (no batching benefit).""" + return [await self.run(req) for req in requests] diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py new file mode 100644 index 000000000..591812f06 --- /dev/null +++ b/src/libtmux/experimental/engines/control_mode.py @@ -0,0 +1,503 @@ +"""A persistent control-mode (``tmux -C``) engine. + +Holds one long-lived ``tmux -C`` connection and pipelines command lines over it, +parsing each command's ``%begin``/``%end``/``%error`` block back into a +:class:`~.base.CommandResult`. Because it returns the same typed result the +subprocess engine does, an operation run through control mode is +indistinguishable -- at the result level -- from one run through a fork-per-call +subprocess. Adapted from the chainable-commands control runner and the +``libtmux-protocol-engines`` parser. + +The parser (:class:`ControlModeParser`) is I/O-free: it consumes bytes and emits +parsed blocks, so it is unit-testable without spawning tmux. ``run_batch`` writes +all command lines at once and collects one block per command, which is the +control engine's advantage over per-call subprocess startup. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import logging +import os +import selectors +import shutil +import subprocess +import threading +import time +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult, render_control_line + +if t.TYPE_CHECKING: + import types + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + +logger = logging.getLogger(__name__) + +_BEGIN_PREFIX = b"%begin " +_END_PREFIX = b"%end " +_ERROR_PREFIX = b"%error " +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_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.""" + + number: int + flags: int + is_error: bool + body: tuple[bytes, ...] + + +@dataclasses.dataclass(slots=True) +class _PendingBlock: + number: int + flags: int + body: list[bytes] + + +class ControlModeParser: + r"""I/O-free parser for the command-block subset of control mode. + + Examples + -------- + >>> parser = ControlModeParser() + >>> parser.feed(b"%begin 1 1 1\nhello\n%end 1 1 1\n") + >>> [block.body for block in parser.blocks()] + [(b'hello',)] + >>> parser.feed(b"%begin 2 2 1\nboom\n%error 2 2 1\n") + >>> block = parser.blocks()[0] + >>> block.is_error, block.body + (True, (b'boom',)) + """ + + __slots__ = ("_blocks", "_buffer", "_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: + return + self._pending = _PendingBlock(number=number, flags=flags or 0, body=[]) + + def _close_block(self, line: bytes) -> None: + pending = self._pending + self._pending = None + if pending is None: + return + self._blocks.append( + ControlModeBlock( + number=pending.number, + flags=pending.flags, + is_error=line.startswith(_ERROR_PREFIX), + body=tuple(pending.body), + ), + ) + + +class ControlModeEngine: + """Execute tmux commands over one persistent ``tmux -C`` connection. + + Parameters + ---------- + tmux_bin : str or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before ``-C``. + timeout : float + Seconds to wait for a batch of result blocks before raising. + + Notes + ----- + The connection is opened lazily on first use. Call :meth:`close` (or use the + engine as a context manager) to tear it down. + """ + + def __init__( + self, + tmux_bin: str | None = None, + *, + server_args: Sequence[str] = (), + timeout: float = _DEFAULT_TIMEOUT, + ) -> None: + self.tmux_bin = tmux_bin + self.server_args = tuple(server_args) + self.timeout = timeout + self._lock = threading.Lock() + self._parser = ControlModeParser() + self._proc: subprocess.Popen[bytes] | None = None + self._selector: selectors.DefaultSelector | None = None + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command over the control connection.""" + return self.run_batch([request])[0] + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Pipeline a batch of commands; one result per request. + + A ``;``-folded request runs as several tmux commands, so its blocks are + grouped (by ``;``-count) and merged into one result. + """ + if not requests: + return [] + rendered = [tuple(req.args) for req in requests] + counts = [command_count(argv) for argv in rendered] + with self._lock: + self._ensure_started() + # Discard any unsolicited blocks (hook-triggered commands) left + # buffered from earlier activity, so they cannot be mis-attributed + # to this batch's commands. + self._drain_unsolicited() + payload = b"".join( + (render_control_line(argv) + "\n").encode() for argv in rendered + ) + self._write(payload) + blocks = self._read_blocks(sum(counts)) + results: list[CommandResult] = [] + index = 0 + for argv, count in zip(rendered, counts, strict=True): + results.append(_merge_blocks(blocks[index : index + count], argv)) + index += count + return results + + def close(self) -> None: + """Tear down the control-mode subprocess (lock-guarded).""" + with self._lock: + proc = self._proc + selector = self._selector + self._proc = None + self._selector = None + self._parser = ControlModeParser() + if selector is not None: + with contextlib.suppress(Exception): + selector.close() + if proc is None: + return + if proc.stdin is not None and not proc.stdin.closed: + with contextlib.suppress(OSError): + proc.stdin.close() + if not _wait_for_exit(proc, _GRACEFUL_EXIT_TIMEOUT): + with contextlib.suppress(OSError): + proc.terminate() + if not _wait_for_exit(proc, _TERMINATE_TIMEOUT): + with contextlib.suppress(OSError): + proc.kill() + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=_TERMINATE_TIMEOUT) + + def __enter__(self) -> ControlModeEngine: + """Return this engine.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Tear down the connection on context exit.""" + self.close() + + def _ensure_started(self) -> None: + if self._proc is not None: + if self._proc.poll() is not None: + msg = f"tmux -C exited with code {self._proc.returncode}" + raise ControlModeError(msg) + return + tmux_bin = self.tmux_bin or shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + cmd = [tmux_bin, *self.server_args, "-C"] + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + if proc.stdin is None or proc.stdout is None or proc.stderr is None: + with contextlib.suppress(OSError): + proc.kill() + msg = "tmux -C subprocess pipes are unavailable" + raise ControlModeError(msg) + os.set_blocking(proc.stdout.fileno(), False) + os.set_blocking(proc.stderr.fileno(), False) + selector = selectors.DefaultSelector() + selector.register(proc.stdout, selectors.EVENT_READ, "stdout") + selector.register(proc.stderr, selectors.EVENT_READ, "stderr") + self._proc = proc + self._selector = selector + self._consume_startup() + + def _consume_startup(self) -> None: + """Read and discard tmux's startup ACK block before any command. + + Consuming it up front (instead of skipping the first block heuristically + at read time) means the startup block can never be conflated with a + command's result block. + """ + deadline = time.monotonic() + _STARTUP_TIMEOUT + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return + if self._proc is not None and self._proc.poll() is not None: + return + self._pump(remaining) + if self._parser.blocks(): # startup ACK seen and discarded + self._parser.notifications() + return + self._parser.notifications() + + def _drain_unsolicited(self) -> None: + """Discard any blocks/notifications already buffered (non-blocking).""" + selector = self._selector + if selector is None: + return + while selector.select(0): + self._pump(0) + self._parser.blocks() + self._parser.notifications() + + def _pump(self, timeout: float) -> None: + """Wait up to *timeout* for output and feed it to the parser.""" + selector = self._selector + if selector is None: + return + for key, _events in selector.select(timeout): + if key.data == "stdout": + self._read_stdout() + elif key.data == "stderr": + self._read_stderr() + + def _write(self, payload: bytes) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + try: + proc.stdin.write(payload) + proc.stdin.flush() + except (BrokenPipeError, OSError) as error: + msg = f"tmux control-mode write failed: {error}" + raise ControlModeError(msg) from error + + def _read_blocks(self, count: int) -> list[ControlModeBlock]: + proc = self._proc + selector = self._selector + if proc is None or selector is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + blocks: list[ControlModeBlock] = [] + deadline = time.monotonic() + self.timeout + while len(blocks) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + msg = ( + f"tmux control-mode timed out after {self.timeout}s " + f"waiting for {count} result blocks" + ) + raise ControlModeError(msg) + ready = selector.select(min(remaining, 0.1)) + if not ready: + if proc.poll() is not None: + msg = f"tmux -C exited with code {proc.returncode}" + raise ControlModeError(msg) + continue + for key, _events in ready: + if key.data == "stdout": + self._read_stdout() + elif key.data == "stderr": + self._read_stderr() + for block in self._parser.blocks(): + # Skip unsolicited blocks (hook-triggered commands carry flags 0); + # only solicited command blocks (flags 1) belong to this batch. + if block.flags == 1 and len(blocks) < count: + blocks.append(block) + self._parser.notifications() # sync engine ignores notifications + return blocks + + def _read_stdout(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + chunk = os.read(proc.stdout.fileno(), _READ_CHUNK) + except BlockingIOError: + return + except OSError as error: + msg = f"tmux control-mode stdout read failed: {error}" + raise ControlModeError(msg) from error + if not chunk: + msg = "tmux -C closed stdout" + raise ControlModeError(msg) + self._parser.feed(chunk) + + def _read_stderr(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + try: + chunk = os.read(proc.stderr.fileno(), _READ_CHUNK) + except (BlockingIOError, OSError): + return + if chunk: + logger.debug( + "tmux control-mode stderr", + extra={"tmux_stderr": [chunk.decode(errors="replace")]}, + ) + + @classmethod + def for_server(cls, server: t.Any, **kwargs: t.Any) -> ControlModeEngine: + """Build a control-mode engine bound to a live server's socket.""" + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls( + tmux_bin=getattr(server, "tmux_bin", None), + server_args=server_args, + **kwargs, + ) + + +def _wait_for_exit(proc: subprocess.Popen[bytes], timeout: float) -> bool: + """Wait up to *timeout* for the process to exit; return whether it did.""" + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + return False + return True + + +def _parse_guard(line: bytes, prefix: bytes) -> tuple[int | None, int | None]: + """Parse a ``%begin``/``%end``/``%error`` guard's number and flags.""" + parts = line[len(prefix) :].split() + if len(parts) < _GUARD_MIN_PARTS: + return (None, None) + try: + return (int(parts[1]), int(parts[2])) + except ValueError: + return (None, None) + + +def _matches_pending_close(line: bytes, pending_number: int) -> bool: + """Whether *line* closes the pending block numbered *pending_number*.""" + for prefix in (_END_PREFIX, _ERROR_PREFIX): + if line.startswith(prefix): + number, _flags = _parse_guard(line, prefix) + return number == pending_number + return False + + +def command_count(argv: tuple[str, ...]) -> int: + """How many tmux commands a rendered argv runs (bare ``;`` separators + 1).""" + return sum(1 for token in argv if token == ";") + 1 + + +def _merge_blocks( + blocks: Sequence[ControlModeBlock], + argv: tuple[str, ...], +) -> CommandResult: + """Merge one request's blocks (one per ``;``-folded sub-command) into a result. + + A ``;``-folded line runs as several tmux commands, each emitting its own + block; stdout/stderr are concatenated and the result fails if any sub-command + errored, matching the subprocess engine's view of one ``;`` chain process. + """ + cmd = ("tmux", "-C", *argv) + stdout: list[str] = [] + stderr: list[str] = [] + returncode = 0 + for block in blocks: + lines = tuple(line.decode(errors="replace") for line in block.body) + if block.is_error: + stderr.extend(lines) + returncode = returncode or 1 + else: + stdout.extend(lines) + return CommandResult( + cmd=cmd, + stdout=_trim(tuple(stdout)), + stderr=_trim(tuple(stderr)), + returncode=returncode, + ) + + +def _trim(lines: tuple[str, ...]) -> tuple[str, ...]: + """Drop trailing blank lines.""" + trimmed = list(lines) + while trimmed and not trimmed[-1].strip(): + trimmed.pop() + return tuple(trimmed) diff --git a/src/libtmux/experimental/engines/imsg/__init__.py b/src/libtmux/experimental/engines/imsg/__init__.py new file mode 100644 index 000000000..ee0426b94 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/__init__.py @@ -0,0 +1,29 @@ +"""Experimental native imsg engine -- an opt-in easter egg. + +Speaks tmux's binary peer protocol (imsg over the server's ``AF_UNIX`` socket) +directly, with no tmux CLI fork per command. It is the strongest proof that the +operation/result contract is transport-agnostic: it returns the *same* +:class:`~..base.CommandResult` as the subprocess and control-mode engines. + +Caveats (why it is opt-in and not the default): it depends on tmux's *internal* +protocol (``PROTOCOL_VERSION`` 8 only; upstream may bump it), it is POSIX-only +(``AF_UNIX`` + ``SCM_RIGHTS`` fd-passing), and it cannot host ``attach-session`` +(which falls back to a local spawn). Importing this triggers registration under +the ``imsg`` engine name. +""" + +from __future__ import annotations + +from libtmux.experimental.engines.imsg.base import ImsgEngine +from libtmux.experimental.engines.imsg.exc import ( + ImsgError, + ImsgProtocolError, + UnsupportedProtocolVersion, +) + +__all__ = ( + "ImsgEngine", + "ImsgError", + "ImsgProtocolError", + "UnsupportedProtocolVersion", +) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py new file mode 100644 index 000000000..61e03f221 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -0,0 +1,903 @@ +"""Shared primitives for tmux imsg protocol engines.""" + +from __future__ import annotations + +import array +import contextlib +import errno +import logging +import os +import pathlib +import selectors +import shutil +import socket +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandRequest, CommandResult +from libtmux.experimental.engines.imsg.exc import ( + ImsgProtocolError, + UnsupportedProtocolVersion, +) +from libtmux.experimental.engines.imsg.types import ImsgFrame, ImsgHeader +from libtmux.experimental.engines.imsg.v8 import ProtocolV8Codec +from libtmux.experimental.engines.registry import register_engine + + +def _select_codec(version: int | str) -> ImsgProtocolCodec: + """Return the codec for a tmux imsg protocol version (only v8 is supported).""" + if str(version) == ProtocolV8Codec.version: + return ProtocolV8Codec() + raise UnsupportedProtocolVersion(str(version)) + + +logger = logging.getLogger(__name__) + +_MAX_IMSGSIZE = 16384 +_IMSG_HEADER_SIZE = 16 +_ExitStatus = tuple[int, str | None] +_CLIENT_UTF8 = 0x10000 + + +class ImsgProtocolCodec(t.Protocol): + """Protocol for versioned tmux imsg codecs.""" + + version: str + + def pack_frame(self, frame: ImsgFrame) -> bytes: + """Return wire bytes for a typed imsg frame.""" + + def pack_message(self, msg_type: int, payload: bytes, *, peer_id: int) -> bytes: + """Return a framed tmux imsg message without an attached FD.""" + + def unpack_header(self, data: bytes) -> ImsgHeader: + """Decode a tmux imsg header.""" + + def identify_messages( + self, + *, + cwd: str, + term: str, + tty_name: str, + client_pid: int, + environ: dict[str, str], + flags: int = 0, + features: int = 0, + stdin_fd: int | None = None, + stdout_fd: int | None = None, + ) -> list[ImsgFrame]: + """Build the identify handshake messages for a tmux client.""" + + def command_message(self, argv: tuple[str, ...], *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_COMMAND`` frame.""" + + def parse_message( + self, + msg_type: int, + payload: bytes, + *, + peer_id: int, + pid: int, + ) -> object: + """Parse a typed tmux message payload.""" + + def exit_status_from_message( + self, + message: object, + ) -> _ExitStatus | None: + """Return exit metadata if the parsed message encodes it.""" + + def write_open_stream(self, message: object) -> int | None: + """Return the declared stream id from a ``MSG_WRITE_OPEN`` message.""" + + def write_payload(self, message: object) -> tuple[int, bytes] | None: + """Return stream id and bytes from a ``MSG_WRITE`` message.""" + + def write_close_stream(self, message: object) -> int | None: + """Return the closed stream id from a ``MSG_WRITE_CLOSE`` message.""" + + def read_open_stream(self, message: object) -> int | None: + """Return the declared stream id from a ``MSG_READ_OPEN`` message.""" + + def write_ready_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_WRITE_READY`` reply.""" + + def read_done_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_READ_DONE`` reply.""" + + @property + def msg_version(self) -> int: + """Return the numeric ``MSG_VERSION`` message type.""" + + @property + def msg_ready(self) -> int: + """Return the numeric ``MSG_READY`` message type.""" + + @property + def msg_exit(self) -> int: + """Return the numeric ``MSG_EXIT`` message type.""" + + @property + def msg_exited(self) -> int: + """Return the numeric ``MSG_EXITED`` message type.""" + + @property + def msg_shutdown(self) -> int: + """Return the numeric ``MSG_SHUTDOWN`` message type.""" + + @property + def msg_flags(self) -> int: + """Return the numeric ``MSG_FLAGS`` message type.""" + + @property + def msg_write_open(self) -> int: + """Return the numeric ``MSG_WRITE_OPEN`` message type.""" + + @property + def msg_write(self) -> int: + """Return the numeric ``MSG_WRITE`` message type.""" + + @property + def msg_write_close(self) -> int: + """Return the numeric ``MSG_WRITE_CLOSE`` message type.""" + + @property + def msg_read_open(self) -> int: + """Return the numeric ``MSG_READ_OPEN`` message type.""" + + @property + def msg_exiting(self) -> int: + """Return the numeric ``MSG_EXITING`` message type.""" + + def exiting_message(self, *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_EXITING`` notification.""" + + +class _ImsgCommandArgs(t.NamedTuple): + """Parsed tmux CLI arguments needed by the imsg engine.""" + + global_args: tuple[str, ...] + command_argv: tuple[str, ...] + socket_name: str | None + socket_path: str | None + config_file: str | None + command_name: str | None + + +class ImsgEngine: + """Execute tmux commands via the native binary imsg socket protocol.""" + + _startserver_commands = frozenset({"new-session", "start-server"}) + + # Subcommands that ultimately invoke tmux's ``spawn.c`` to start a + # shell. tmux uses the client's environ (built from + # ``MSG_IDENTIFY_ENVIRON`` frames per ``server-client.c:3685``) only + # when actually launching a shell process; for queries / metadata + # commands the environ is allocated, populated, then freed at + # ``MSG_EXIT`` without ever being read. Forwarding the full + # ``os.environ`` (typically ~50-100 vars) per call cost ~one frame + # per env var on the wire — net waste outside the spawn paths. + # See: ``cmd-new-session.c:273``, ``spawn.c:314-324``, + # ``cmd-{new,split,respawn}-{window,pane}.c``, + # ``cmd-display-popup.c``, ``source-file.c``. + _spawn_commands = frozenset( + { + "new-session", + "new-window", + "split-window", + "respawn-pane", + "respawn-window", + "display-popup", + # source-file can run arbitrary commands inside the loaded + # config, including ones that spawn — conservative include + # keeps user expectations stable across engines. + "source-file", + }, + ) + + # Env keys tmux looks up *by name* on the client environ outside + # the shell-spawning paths. Currently a single key: + # * ``TMUX_PANE`` — ``cmd-find.c:93``'s + # ``cmd_find_inside_pane`` fallback uses it to identify which + # pane the calling client is "inside of" when no ``-t`` target + # is given (nested-tmux scenarios — libtmux running from a + # pane inside an existing tmux server). Forwarding it for + # every command keeps the imsg engine's default-target + # resolution semantically identical to subprocess engine. + # ``TMUX`` is also looked up (``server-client.c:240``) but only + # by ``attach-session`` to refuse nested-attach; libtmux hard-routes + # ``attach-session`` through subprocess so the imsg engine never + # exercises that path. + _probe_env_keys = ("TMUX_PANE",) + + def __init__(self, protocol_version: str | int | None = None) -> None: + self.protocol_version = ( + str(protocol_version) if protocol_version is not None else None + ) + self._resolved_tmux_bin: str | None = None + + def run(self, request: CommandRequest) -> CommandResult: + """Execute a tmux command over the server socket.""" + tmux_bin = request.tmux_bin or self._resolve_tmux_bin() + parsed = self._parse_args(request.args) + cmd = [tmux_bin, *parsed.global_args, *parsed.command_argv] + + if parsed.command_name is None or parsed.command_name == "-V": + return self._run_local_command(cmd) + + socket_path = self._resolve_socket_path(parsed) + if parsed.command_name == "start-server": + return self._run_local_command(cmd) + if parsed.command_name in self._startserver_commands and not _server_available( + socket_path + ): + return self._run_local_command(cmd) + + peer_id = int(self.protocol_version or ProtocolV8Codec.version) + retries_remaining = 1 + + while True: + sock: socket.socket | None = None + codec = _select_codec(peer_id) + try: + sock = self._connect(socket_path=socket_path) + return self._run_socket_command( + sock=sock, + codec=codec, + peer_id=peer_id, + command_name=parsed.command_name, + command_argv=parsed.command_argv, + cmd=cmd, + ) + except _NoServerError as error: + if parsed.command_name in self._startserver_commands: + return self._run_local_command(cmd) + return CommandResult( + cmd=tuple(cmd), + stdout=(), + stderr=(error.message,), + returncode=1, + ) + except (BrokenPipeError, ConnectionResetError) as error: + # Server began shutdown between connect() and the first + # send/recv: the socket file still exists so connect succeeded, + # but the kernel returns EPIPE/ECONNRESET on the first I/O. + # Mirror tmux's CLIENT_EXIT_LOST_SERVER behavior — present a + # clean "no server running" CommandResult instead of leaking + # the transport exception. + if parsed.command_name in self._startserver_commands: + return self._run_local_command(cmd) + return CommandResult( + cmd=tuple(cmd), + stdout=(), + stderr=(self._no_server_message(socket_path, error),), + returncode=1, + ) + except _ProtocolVersionMismatch as mismatch: + if retries_remaining == 0: + raise UnsupportedProtocolVersion( + mismatch.server_version, + ) from None + retries_remaining -= 1 + peer_id = int(mismatch.server_version) + self.protocol_version = mismatch.server_version + finally: + if sock is not None: + sock.close() + + def _resolve_tmux_bin(self) -> str: + """Return the tmux binary path, memoized per engine instance. + + ``shutil.which`` walks ``$PATH`` on every call (~50µs); the engine + invokes it on the hot path of every command, so caching the + result for the lifetime of the engine instance is a free win. + ``TmuxCommandNotFound`` is intentionally not memoized. + """ + if self._resolved_tmux_bin is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved_tmux_bin = resolved + return self._resolved_tmux_bin + + def run_batch( + self, + requests: t.Sequence[CommandRequest], + ) -> list[CommandResult]: + """Loop over ``run`` — imsg opens a fresh socket per call. + + No batching benefit but provided for uniform API: callers can + use ``run_batch`` regardless of engine and get the right + ordered list of results. + """ + return [self.run(req) for req in requests] + + def _parse_args(self, args: tuple[str, ...]) -> _ImsgCommandArgs: + global_args: list[str] = [] + command_argv: list[str] = [] + socket_name: str | None = None + socket_path: str | None = None + config_file: str | None = None + + index = 0 + while index < len(args): + arg = args[index] + if arg == "-V": + command_argv.append(arg) + break + if arg in {"-L", "-S", "-f"}: + if index + 1 >= len(args): + command_argv.append(arg) + break + value = args[index + 1] + global_args.extend((arg, value)) + if arg == "-L": + socket_name = value + elif arg == "-S": + socket_path = value + else: + config_file = value + index += 2 + continue + if arg.startswith("-L") and len(arg) > 2: + socket_name = arg[2:] + global_args.append(arg) + index += 1 + continue + if arg.startswith("-S") and len(arg) > 2: + socket_path = arg[2:] + global_args.append(arg) + index += 1 + continue + if arg.startswith("-f") and len(arg) > 2: + config_file = arg[2:] + global_args.append(arg) + index += 1 + continue + if arg in {"-2", "-8"}: + global_args.append(arg) + index += 1 + continue + + command_argv.extend(args[index:]) + break + + command_name = command_argv[0] if command_argv else None + return _ImsgCommandArgs( + global_args=tuple(global_args), + command_argv=tuple(command_argv), + socket_name=socket_name, + socket_path=socket_path, + config_file=config_file, + command_name=command_name, + ) + + def _resolve_socket_path(self, parsed: _ImsgCommandArgs) -> str: + if parsed.socket_path is not None: + return parsed.socket_path + + socket_name = parsed.socket_name or "default" + tmux_tmpdir = pathlib.Path(os.getenv("TMUX_TMPDIR", "/tmp")) + return str(tmux_tmpdir / f"tmux-{os.geteuid()}" / socket_name) + + def _connect( + self, + *, + socket_path: str, + ) -> socket.socket: + # Create the socket outside the try so the except never references an + # unbound `sock` if socket() itself fails (e.g. fd exhaustion). + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(socket_path) + except OSError as error: + sock.close() + if error.errno not in {errno.ENOENT, errno.ECONNREFUSED}: + raise + raise _NoServerError( + self._no_server_message(socket_path, error), + ) from error + return sock + + def _no_server_message(self, socket_path: str, error: OSError) -> str: + if error.errno == errno.ECONNREFUSED: + return f"error connecting to {socket_path}" + return f"no server running on {socket_path}" + + def _client_flags(self) -> int: + if os.environ.get("TMUX"): + return _CLIENT_UTF8 + + locale = ( + os.environ.get("LC_ALL") + or os.environ.get("LC_CTYPE") + or os.environ.get("LANG") + or "" + ) + locale = locale.upper() + if "UTF-8" in locale or "UTF8" in locale: + return _CLIENT_UTF8 + return 0 + + def _run_local_command(self, cmd: list[str]) -> CommandResult: + exit_code, stdout, stderr = _spawn_and_capture(cmd) + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout), + stderr=tuple(stderr), + returncode=exit_code, + ) + + def _run_socket_command( + self, + *, + sock: socket.socket, + codec: ImsgProtocolCodec, + peer_id: int, + command_name: str | None, + command_argv: tuple[str, ...], + cmd: list[str], + ) -> CommandResult: + stdin_fd = _duplicate_fd(0) + stdout_fd = _duplicate_fd(1) + # Gated env forwarding: tmux only reads ``c->environ`` from + # ``spawn.c`` (and its callers) when actually launching a + # shell — every other command path frees ``c->environ`` + # without reading it. Sending the full ``os.environ`` would + # cost one ``MSG_IDENTIFY_ENVIRON`` frame per env var (~89 on + # this host) per command; for non-spawning commands those + # frames are net waste. Forward the full env only when + # ``command_name`` is in :attr:`_spawn_commands`; for everything + # else, forward only :attr:`_probe_env_keys` so tmux's + # named-lookup paths (``cmd-find.c``'s ``TMUX_PANE`` fallback) + # keep matching subprocess-engine semantics. + if command_name in self._spawn_commands: + environ_to_send: dict[str, str] = dict(os.environ) + else: + environ_to_send = { + key: os.environ[key] + for key in self._probe_env_keys + if key in os.environ + } + # The dup'd fds are owned by send_frames once the identify burst is sent; + # close them if building the frames or opening the transport fails first. + try: + identify_frames = codec.identify_messages( + cwd=str(pathlib.Path.cwd()), + term=os.environ.get("TERM", "unknown") or "unknown", + tty_name="", + client_pid=os.getpid(), + environ=environ_to_send, + flags=self._client_flags(), + features=0, + stdin_fd=stdin_fd, + stdout_fd=stdout_fd, + ) + except BaseException: + _close_fd(stdin_fd) + _close_fd(stdout_fd) + raise + logger.debug( + "sending imsg identify burst", + extra={ + "tmux_protocol_version": codec.version, + "tmux_identify_frames": len(identify_frames), + "tmux_cmd": " ".join(command_argv), + }, + ) + + stdout_streams: set[int] = set() + stderr_streams: set[int] = set() + stdout_buffer = bytearray() + stderr_buffer = bytearray() + exit_code = 0 + exit_message: str | None = None + seen_exit = False + + try: + transport = _SelectorSocketTransport(sock) + except BaseException: + _close_fd(stdin_fd) + _close_fd(stdout_fd) + raise + try: + transport.send_frames(codec, identify_frames) + command_frame = codec.command_message(command_argv, peer_id=peer_id) + transport.send_frame(codec, command_frame) + + while True: + try: + frame = transport.recv_frame(codec) + except ImsgProtocolError: + # The server may close the socket right after MSG_EXIT, + # before MSG_EXITED; the exit result is already computed. + if seen_exit: + break + raise + msg_type = frame.header.msg_type + peer = frame.header.peer_id + pid = frame.header.pid + payload = frame.payload + logger.debug( + "received imsg message", + extra={ + "tmux_protocol_version": codec.version, + "tmux_message_type": msg_type, + "tmux_message_peer": peer, + "tmux_message_pid": pid, + "tmux_message_len": len(payload), + "tmux_message_has_fd": frame.header.has_fd, + "tmux_cmd": " ".join(command_argv), + }, + ) + if msg_type == codec.msg_version: + _close_fd(frame.fd) + raise _ProtocolVersionMismatch(str(peer & 0xFF)) + + try: + message: object = codec.parse_message( + msg_type, + payload, + peer_id=peer, + pid=pid, + ) + finally: + _close_fd(frame.fd) + + if msg_type == codec.msg_ready: + continue + if msg_type == codec.msg_flags: + continue + + stream = codec.write_open_stream(message) + if stream is not None: + if stream == 2: + stderr_streams.add(stream) + else: + stdout_streams.add(stream) + transport.send_frame( + codec, + codec.write_ready_message(stream, 0, peer_id=peer_id), + ) + continue + + payload_data = codec.write_payload(message) + if payload_data is not None: + stream_id, data = payload_data + if stream_id in stderr_streams: + stderr_buffer.extend(data) + else: + stdout_buffer.extend(data) + continue + + close_stream = codec.write_close_stream(message) + if close_stream is not None: + continue + + read_stream = codec.read_open_stream(message) + if read_stream is not None: + transport.send_frame( + codec, + codec.read_done_message( + read_stream, + errno.EBADF, + peer_id=peer_id, + ), + ) + continue + + exit_status = codec.exit_status_from_message(message) + if exit_status is not None: + exit_code, exit_message = exit_status + seen_exit = True + transport.send_frame( + codec, + codec.exiting_message(peer_id=peer_id), + ) + continue + + if msg_type == codec.msg_shutdown: + exit_code = 1 + seen_exit = True + transport.send_frame( + codec, + codec.exiting_message(peer_id=peer_id), + ) + continue + + if msg_type == codec.msg_exited: + break + + if seen_exit: + break + finally: + transport.close() + + stdout_lines = _split_output(bytes(stdout_buffer)) + stderr_lines = _split_output(bytes(stderr_buffer)) + if exit_message: + stderr_lines.append(exit_message) + + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout_lines), + stderr=tuple(stderr_lines), + returncode=exit_code, + ) + + +class _ProtocolVersionMismatch(RuntimeError): + """Internal signal for retrying with a negotiated protocol version.""" + + def __init__(self, server_version: str) -> None: + super().__init__(server_version) + self.server_version = server_version + + +class _NoServerError(RuntimeError): + """Internal signal for commands against a missing tmux socket.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +class _SelectorSocketTransport: + """Selector-backed imsg transport for Unix domain sockets.""" + + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.sock.setblocking(False) + self._selector = selectors.DefaultSelector() + self._selector.register(sock, selectors.EVENT_READ) + self._buffer = bytearray() + self._pending_fds: list[int] = [] + + def close(self) -> None: + """Close selector state and any unclaimed descriptors.""" + with contextlib.suppress(KeyError, ValueError): + self._selector.unregister(self.sock) + self._selector.close() + for fd in self._pending_fds: + _close_fd(fd) + self._pending_fds.clear() + + def send_frames( + self, + codec: ImsgProtocolCodec, + frames: list[ImsgFrame], + ) -> None: + """Send a sequence of frames and close unsent descriptors on failure.""" + sent = 0 + try: + for frame in frames: + self.send_frame(codec, frame) + sent += 1 + finally: + for frame in frames[sent:]: + _close_fd(frame.fd) + + def send_frame(self, codec: ImsgProtocolCodec, frame: ImsgFrame) -> None: + """Send one imsg frame, including an optional SCM_RIGHTS descriptor.""" + data = codec.pack_frame(frame) + if frame.fd is not None: + self._send_frame_with_fd(data, frame.fd) + return + self._send_all(data) + + def recv_frame(self, codec: ImsgProtocolCodec) -> ImsgFrame: + """Receive one complete imsg frame.""" + while len(self._buffer) < _IMSG_HEADER_SIZE: + self._recv_more() + + header = codec.unpack_header(bytes(self._buffer[:_IMSG_HEADER_SIZE])) + while len(self._buffer) < header.length: + self._recv_more() + + payload = bytes(self._buffer[_IMSG_HEADER_SIZE : header.length]) + del self._buffer[: header.length] + + fd: int | None = None + if header.has_fd and self._pending_fds: + fd = self._pending_fds.pop(0) + + return ImsgFrame(header=header, payload=payload, fd=fd) + + def _wait_for(self, event: int) -> None: + self._selector.modify(self.sock, event) + self._selector.select() + + def _send_all(self, data: bytes) -> None: + # Optimistic send: tmux's imsg frames are tiny (≤16 KiB) and a + # fresh AF_UNIX SOCK_STREAM socket has plenty of buffer + # capacity, so the first send almost always succeeds. Hitting + # the selector only on real BlockingIOError replaces two + # syscalls per send (selector.modify + epoll_wait) with zero + # in the common case — a measurable win on the imsg engine + # since it issues ~100 sends per command. + offset = 0 + sock = self.sock + while offset < len(data): + try: + sent = sock.send(data[offset:]) + except BlockingIOError: + self._wait_for(selectors.EVENT_WRITE) + continue + if sent == 0: + msg = "tmux socket closed during protocol write" + raise ImsgProtocolError(msg) + offset += sent + + def _send_frame_with_fd(self, data: bytes, fd: int) -> None: + fds = array.array("i", [fd]) + ancillary = [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds.tobytes())] + try: + sock = self.sock + while True: + try: + sent = sock.sendmsg([data], ancillary) + except BlockingIOError: + self._wait_for(selectors.EVENT_WRITE) + continue + break + if sent != len(data): + msg = "tmux imsg frame with FD was partially written" + raise ImsgProtocolError(msg) + finally: + _close_fd(fd) + + def _recv_more(self) -> None: + # Symmetric optimistic recv: the wire half of every imsg + # exchange is paced by tmux's reply rate, so by the time + # Python re-enters the read loop the kernel buffer typically + # already has bytes. Skip the upfront selector wait and only + # block on real BlockingIOError. + fd_size = array.array("i").itemsize + sock = self.sock + while True: + try: + data, ancillary, _flags, _addr = sock.recvmsg( + 65535, + socket.CMSG_SPACE(fd_size), + ) + except BlockingIOError: + self._wait_for(selectors.EVENT_READ) + continue + break + if not data: + msg = "tmux socket closed during protocol exchange" + raise ImsgProtocolError(msg) + + self._buffer.extend(data) + for level, msg_type, cmsg_data in ancillary: + if level != socket.SOL_SOCKET or msg_type != socket.SCM_RIGHTS: + continue + fds = array.array("i") + fds.frombytes(cmsg_data[: len(cmsg_data) - (len(cmsg_data) % fd_size)]) + for index, fd in enumerate(fds): + if index == 0: + self._pending_fds.append(fd) + else: + _close_fd(fd) + + +def _spawn_and_capture(command: list[str]) -> tuple[int, list[str], list[str]]: + """Run a command without subprocess and capture its output.""" + stdout_read, stdout_write = os.pipe() + stderr_read, stderr_write = os.pipe() + file_actions = [ + (os.POSIX_SPAWN_DUP2, stdout_write, 1), + (os.POSIX_SPAWN_DUP2, stderr_write, 2), + (os.POSIX_SPAWN_CLOSE, stdout_read), + (os.POSIX_SPAWN_CLOSE, stderr_read), + ] + + try: + if "/" in command[0]: + pid = os.posix_spawn( + command[0], + command, + os.environ, + file_actions=file_actions, + ) + else: + pid = os.posix_spawnp( + command[0], + command, + os.environ, + file_actions=file_actions, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + finally: + os.close(stdout_write) + os.close(stderr_write) + + stdout_chunks: list[bytes] = [] + stderr_chunks: list[bytes] = [] + + os.set_blocking(stdout_read, False) + os.set_blocking(stderr_read, False) + + selector = selectors.DefaultSelector() + streams = { + stdout_read: stdout_chunks, + stderr_read: stderr_chunks, + } + selector.register(stdout_read, selectors.EVENT_READ) + selector.register(stderr_read, selectors.EVENT_READ) + + try: + while streams: + for key, _mask in selector.select(): + fd = key.fd + try: + chunk = os.read(fd, 65535) + except BlockingIOError: + continue + if chunk: + streams[fd].append(chunk) + continue + selector.unregister(fd) + del streams[fd] + finally: + selector.close() + + os.close(stdout_read) + os.close(stderr_read) + _pid, status = os.waitpid(pid, 0) + exit_code = os.waitstatus_to_exitcode(status) + + stdout_lines = _split_output(b"".join(stdout_chunks)) + stderr_lines = _split_output(b"".join(stderr_chunks)) + return exit_code, stdout_lines, stderr_lines + + +def _duplicate_fd(fd: int) -> int | None: + """Duplicate a descriptor for SCM_RIGHTS ownership transfer.""" + with contextlib.suppress(OSError): + return os.dup(fd) + return None + + +def _close_fd(fd: int | None) -> None: + """Close a descriptor if one is present.""" + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + + +def _split_output(data: bytes) -> list[str]: + """Split tmux output into newline-delimited text lines.""" + text = data.decode("utf-8", errors="backslashreplace") + lines = text.split("\n") + while lines and lines[-1] == "": + lines.pop() + return lines + + +def _server_available(socket_path: str) -> bool: + """Return whether a tmux server is currently listening on the socket path.""" + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + probe.connect(socket_path) + except OSError: + return False + finally: + probe.close() + return True + + +register_engine("imsg", ImsgEngine) diff --git a/src/libtmux/experimental/engines/imsg/exc.py b/src/libtmux/experimental/engines/imsg/exc.py new file mode 100644 index 000000000..99d142e37 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/exc.py @@ -0,0 +1,22 @@ +"""Exceptions for the experimental imsg engine.""" + +from __future__ import annotations + +from libtmux.exc import LibTmuxException + + +class ImsgError(LibTmuxException): + """Base error for the native imsg engine.""" + + +class ImsgProtocolError(ImsgError): + """The imsg wire protocol was violated (bad frame, size, or framing).""" + + +class UnsupportedProtocolVersion(ImsgError): + """The tmux server speaks an imsg protocol version this engine lacks.""" + + def __init__(self, version: str) -> None: + self.version = version + msg = f"unsupported tmux imsg protocol version: {version}" + super().__init__(msg) diff --git a/src/libtmux/experimental/engines/imsg/types.py b/src/libtmux/experimental/engines/imsg/types.py new file mode 100644 index 000000000..0812cf26a --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/types.py @@ -0,0 +1,28 @@ +"""Typed imsg frame primitives shared by protocol versions.""" + +from __future__ import annotations + +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class ImsgHeader: + """Decoded imsg header. + + ``length`` is the full frame length without the imsg FD marker bit. + """ + + msg_type: int + length: int + peer_id: int + pid: int + has_fd: bool = False + + +@dataclasses.dataclass(frozen=True) +class ImsgFrame: + """A framed tmux imsg message plus an optional SCM_RIGHTS descriptor.""" + + header: ImsgHeader + payload: bytes = b"" + fd: int | None = None diff --git a/src/libtmux/experimental/engines/imsg/v8.py b/src/libtmux/experimental/engines/imsg/v8.py new file mode 100644 index 000000000..c1687f3f5 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/v8.py @@ -0,0 +1,538 @@ +"""tmux imsg protocol version 8.""" + +from __future__ import annotations + +import dataclasses +import enum +import struct +import typing as t + +from libtmux.experimental.engines.imsg.exc import ImsgProtocolError +from libtmux.experimental.engines.imsg.types import ImsgFrame, ImsgHeader + +IMSG_HEADER_SIZE = 16 +MAX_IMSGSIZE = 16384 +IMSG_FD_MARK = 0x80000000 + +_HEADER = struct.Struct("=IIII") +_INT32 = struct.Struct("=i") +_UINT64 = struct.Struct("=Q") +_WRITE_OPEN = struct.Struct("=iii") +_WRITE_DATA = struct.Struct("=i") +_WRITE_READY = struct.Struct("=ii") +_WRITE_CLOSE = struct.Struct("=i") +_READ_OPEN = struct.Struct("=ii") +_READ_DONE = struct.Struct("=ii") +_ExitStatus = tuple[int, str | None] + + +class MessageType(enum.IntEnum): + """Known tmux protocol v8 message types from ``tmux-protocol.h``.""" + + MSG_VERSION = 12 + MSG_IDENTIFY_FLAGS = 100 + MSG_IDENTIFY_TERM = 101 + MSG_IDENTIFY_TTYNAME = 102 + MSG_IDENTIFY_OLDCWD = 103 + MSG_IDENTIFY_STDIN = 104 + MSG_IDENTIFY_ENVIRON = 105 + MSG_IDENTIFY_DONE = 106 + MSG_IDENTIFY_CLIENTPID = 107 + MSG_IDENTIFY_CWD = 108 + MSG_IDENTIFY_FEATURES = 109 + MSG_IDENTIFY_STDOUT = 110 + MSG_IDENTIFY_LONGFLAGS = 111 + MSG_IDENTIFY_TERMINFO = 112 + MSG_COMMAND = 200 + MSG_DETACH = 201 + MSG_DETACHKILL = 202 + MSG_EXIT = 203 + MSG_EXITED = 204 + MSG_EXITING = 205 + MSG_LOCK = 206 + MSG_READY = 207 + MSG_RESIZE = 208 + MSG_SHELL = 209 + MSG_SHUTDOWN = 210 + MSG_OLDSTDERR = 211 + MSG_OLDSTDIN = 212 + MSG_OLDSTDOUT = 213 + MSG_SUSPEND = 214 + MSG_UNLOCK = 215 + MSG_WAKEUP = 216 + MSG_EXEC = 217 + MSG_FLAGS = 218 + MSG_READ_OPEN = 300 + MSG_READ = 301 + MSG_READ_DONE = 302 + MSG_WRITE_OPEN = 303 + MSG_WRITE = 304 + MSG_WRITE_READY = 305 + MSG_WRITE_CLOSE = 306 + MSG_READ_CANCEL = 307 + + +@dataclasses.dataclass(frozen=True) +class WriteOpenMessage: + """Parsed ``MSG_WRITE_OPEN`` payload.""" + + stream: int + fd: int + flags: int + path: str + + +@dataclasses.dataclass(frozen=True) +class WriteDataMessage: + """Parsed ``MSG_WRITE`` payload.""" + + stream: int + data: bytes + + +@dataclasses.dataclass(frozen=True) +class WriteReadyMessage: + """Parsed ``MSG_WRITE_READY`` payload.""" + + stream: int + error: int + + +@dataclasses.dataclass(frozen=True) +class WriteCloseMessage: + """Parsed ``MSG_WRITE_CLOSE`` payload.""" + + stream: int + + +@dataclasses.dataclass(frozen=True) +class ReadOpenMessage: + """Parsed ``MSG_READ_OPEN`` payload.""" + + stream: int + fd: int + path: str + + +@dataclasses.dataclass(frozen=True) +class ReadDoneMessage: + """Parsed ``MSG_READ_DONE`` payload.""" + + stream: int + error: int + + +@dataclasses.dataclass(frozen=True) +class ExitMessage: + """Parsed ``MSG_EXIT`` payload.""" + + returncode: int + message: str | None + + +@dataclasses.dataclass(frozen=True) +class RawMessage: + """Payload for message types without a dedicated parser.""" + + payload: bytes + + +ParsedMessage: t.TypeAlias = ( + WriteOpenMessage + | WriteDataMessage + | WriteReadyMessage + | WriteCloseMessage + | ReadOpenMessage + | ReadDoneMessage + | ExitMessage + | RawMessage +) + + +class ProtocolV8Codec: + """Typed codec for tmux binary protocol version 8.""" + + version = "8" + + @property + def msg_version(self) -> int: + """Return the numeric ``MSG_VERSION`` message type.""" + return int(MessageType.MSG_VERSION) + + @property + def msg_ready(self) -> int: + """Return the numeric ``MSG_READY`` message type.""" + return int(MessageType.MSG_READY) + + @property + def msg_exit(self) -> int: + """Return the numeric ``MSG_EXIT`` message type.""" + return int(MessageType.MSG_EXIT) + + @property + def msg_exited(self) -> int: + """Return the numeric ``MSG_EXITED`` message type.""" + return int(MessageType.MSG_EXITED) + + @property + def msg_shutdown(self) -> int: + """Return the numeric ``MSG_SHUTDOWN`` message type.""" + return int(MessageType.MSG_SHUTDOWN) + + @property + def msg_flags(self) -> int: + """Return the numeric ``MSG_FLAGS`` message type.""" + return int(MessageType.MSG_FLAGS) + + @property + def msg_write_open(self) -> int: + """Return the numeric ``MSG_WRITE_OPEN`` message type.""" + return int(MessageType.MSG_WRITE_OPEN) + + @property + def msg_write(self) -> int: + """Return the numeric ``MSG_WRITE`` message type.""" + return int(MessageType.MSG_WRITE) + + @property + def msg_write_close(self) -> int: + """Return the numeric ``MSG_WRITE_CLOSE`` message type.""" + return int(MessageType.MSG_WRITE_CLOSE) + + @property + def msg_read_open(self) -> int: + """Return the numeric ``MSG_READ_OPEN`` message type.""" + return int(MessageType.MSG_READ_OPEN) + + @property + def msg_exiting(self) -> int: + """Return the numeric ``MSG_EXITING`` message type.""" + return int(MessageType.MSG_EXITING) + + def frame_message( + self, + msg_type: int | MessageType, + payload: bytes, + *, + peer_id: int, + fd: int | None = None, + ) -> ImsgFrame: + """Return a typed imsg frame.""" + length = IMSG_HEADER_SIZE + len(payload) + if length > MAX_IMSGSIZE: + msg = f"tmux imsg payload too large: {len(payload)} bytes" + raise ImsgProtocolError(msg) + return ImsgFrame( + header=ImsgHeader( + msg_type=int(msg_type), + length=length, + peer_id=peer_id, + pid=0, + has_fd=fd is not None, + ), + payload=payload, + fd=fd, + ) + + def pack_frame(self, frame: ImsgFrame) -> bytes: + """Return wire bytes for a typed imsg frame.""" + expected_payload_len = frame.header.length - IMSG_HEADER_SIZE + if expected_payload_len != len(frame.payload): + msg = ( + "tmux imsg frame length does not match payload size: " + f"{frame.header.length} != {IMSG_HEADER_SIZE + len(frame.payload)}" + ) + raise ImsgProtocolError(msg) + if frame.header.has_fd != (frame.fd is not None): + msg = "tmux imsg frame FD marker does not match descriptor" + raise ImsgProtocolError(msg) + + encoded_length = frame.header.length + if frame.header.has_fd: + encoded_length |= IMSG_FD_MARK + header = _HEADER.pack( + frame.header.msg_type, + encoded_length, + frame.header.peer_id, + frame.header.pid, + ) + return header + frame.payload + + def pack_message( + self, + msg_type: int, + payload: bytes, + *, + peer_id: int, + ) -> bytes: + """Return a framed tmux imsg message without an attached FD.""" + return self.pack_frame( + self.frame_message(msg_type, payload, peer_id=peer_id), + ) + + def unpack_header(self, data: bytes) -> ImsgHeader: + """Decode and validate a tmux imsg header.""" + if len(data) != IMSG_HEADER_SIZE: + msg = f"tmux imsg header must be {IMSG_HEADER_SIZE} bytes" + raise ImsgProtocolError(msg) + + msg_type, encoded_length, peer_id, pid = _HEADER.unpack(data) + has_fd = bool(encoded_length & IMSG_FD_MARK) + length = encoded_length & ~IMSG_FD_MARK + if length < IMSG_HEADER_SIZE or length > MAX_IMSGSIZE: + msg = f"Invalid tmux imsg length: {length}" + raise ImsgProtocolError(msg) + return ImsgHeader( + msg_type=msg_type, + length=length, + peer_id=peer_id, + pid=pid, + has_fd=has_fd, + ) + + def identify_messages( + self, + *, + cwd: str, + term: str, + tty_name: str, + client_pid: int, + environ: dict[str, str], + flags: int = 0, + features: int = 0, + stdin_fd: int | None = None, + stdout_fd: int | None = None, + ) -> list[ImsgFrame]: + """Build the identify handshake messages for a tmux client.""" + peer_id = int(self.version) + messages = [ + self.frame_message( + MessageType.MSG_IDENTIFY_LONGFLAGS, + _UINT64.pack(flags), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_TERM, + _c_string(term), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_FEATURES, + _INT32.pack(features), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_TTYNAME, + _c_string(tty_name), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_CWD, + _c_string(cwd), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_STDIN, + b"", + peer_id=peer_id, + fd=stdin_fd, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_STDOUT, + b"", + peer_id=peer_id, + fd=stdout_fd, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_CLIENTPID, + _INT32.pack(client_pid), + peer_id=peer_id, + ), + ] + for key, value in environ.items(): + encoded = _c_string(f"{key}={value}") + if len(encoded) > MAX_IMSGSIZE - IMSG_HEADER_SIZE: + continue + messages.append( + self.frame_message( + MessageType.MSG_IDENTIFY_ENVIRON, + encoded, + peer_id=peer_id, + ), + ) + messages.append( + self.frame_message( + MessageType.MSG_IDENTIFY_DONE, + b"", + peer_id=peer_id, + ), + ) + return messages + + def command_message(self, argv: tuple[str, ...], *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_COMMAND`` frame.""" + payload = _INT32.pack(len(argv)) + b"".join(_c_string(arg) for arg in argv) + return self.frame_message( + MessageType.MSG_COMMAND, + payload, + peer_id=peer_id, + ) + + def parse_message( + self, + msg_type: int, + payload: bytes, + *, + peer_id: int, + pid: int, + ) -> ParsedMessage: + """Parse a typed tmux message payload.""" + del peer_id, pid + if msg_type == int(MessageType.MSG_WRITE_OPEN): + _require_min_size(payload, _WRITE_OPEN.size, "MSG_WRITE_OPEN") + stream, fd, flags = _WRITE_OPEN.unpack_from(payload) + path = _decode_c_string(payload[_WRITE_OPEN.size :]) + return WriteOpenMessage(stream=stream, fd=fd, flags=flags, path=path) + if msg_type == int(MessageType.MSG_WRITE): + _require_min_size(payload, _WRITE_DATA.size, "MSG_WRITE") + (stream,) = _WRITE_DATA.unpack_from(payload) + return WriteDataMessage(stream=stream, data=payload[_WRITE_DATA.size :]) + if msg_type == int(MessageType.MSG_WRITE_READY): + _require_exact_size(payload, _WRITE_READY.size, "MSG_WRITE_READY") + stream, error = _WRITE_READY.unpack(payload) + return WriteReadyMessage(stream=stream, error=error) + if msg_type == int(MessageType.MSG_WRITE_CLOSE): + _require_exact_size(payload, _WRITE_CLOSE.size, "MSG_WRITE_CLOSE") + (stream,) = _WRITE_CLOSE.unpack(payload) + return WriteCloseMessage(stream=stream) + if msg_type == int(MessageType.MSG_READ_OPEN): + _require_min_size(payload, _READ_OPEN.size, "MSG_READ_OPEN") + stream, fd = _READ_OPEN.unpack_from(payload) + path = _decode_c_string(payload[_READ_OPEN.size :]) + return ReadOpenMessage(stream=stream, fd=fd, path=path) + if msg_type == int(MessageType.MSG_READ_DONE): + _require_exact_size(payload, _READ_DONE.size, "MSG_READ_DONE") + stream, error = _READ_DONE.unpack(payload) + return ReadDoneMessage(stream=stream, error=error) + if msg_type == int(MessageType.MSG_EXIT): + return _parse_exit_message(payload) + return RawMessage(payload=payload) + + def exit_status_from_message(self, message: object) -> _ExitStatus | None: + """Return exit metadata if the parsed message encodes it.""" + if isinstance(message, ExitMessage): + return message.returncode, message.message + return None + + def write_open_stream(self, message: object) -> int | None: + """Return the stream id from a ``MSG_WRITE_OPEN`` message.""" + if isinstance(message, WriteOpenMessage): + return message.stream + return None + + def write_payload(self, message: object) -> tuple[int, bytes] | None: + """Return stream id and bytes from a ``MSG_WRITE`` message.""" + if isinstance(message, WriteDataMessage): + return message.stream, message.data + return None + + def write_close_stream(self, message: object) -> int | None: + """Return the closed stream id from a ``MSG_WRITE_CLOSE`` message.""" + if isinstance(message, WriteCloseMessage): + return message.stream + return None + + def read_open_stream(self, message: object) -> int | None: + """Return the stream id from a ``MSG_READ_OPEN`` message.""" + if isinstance(message, ReadOpenMessage): + return message.stream + return None + + def write_ready_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_WRITE_READY`` reply.""" + return self.frame_message( + MessageType.MSG_WRITE_READY, + _WRITE_READY.pack(stream, error_code), + peer_id=peer_id, + ) + + def read_done_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_READ_DONE`` reply.""" + return self.frame_message( + MessageType.MSG_READ_DONE, + _READ_DONE.pack(stream, error_code), + peer_id=peer_id, + ) + + def exiting_message(self, *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_EXITING`` notification.""" + return self.frame_message(MessageType.MSG_EXITING, b"", peer_id=peer_id) + + +def _c_string(value: str) -> bytes: + return value.encode("utf-8") + b"\0" + + +def _decode_c_string(data: bytes) -> str: + if not data: + return "" + if data[-1] != 0: + msg = "tmux imsg string payload is not NUL terminated" + raise ImsgProtocolError(msg) + return data[:-1].decode("utf-8", errors="backslashreplace") + + +def _require_min_size(payload: bytes, min_size: int, name: str) -> None: + if len(payload) < min_size: + msg = f"bad {name} payload size: {len(payload)}" + raise ImsgProtocolError(msg) + + +def _require_exact_size(payload: bytes, expected_size: int, name: str) -> None: + if len(payload) != expected_size: + msg = f"bad {name} payload size: {len(payload)}" + raise ImsgProtocolError(msg) + + +def _parse_exit_message(payload: bytes) -> ExitMessage: + if len(payload) < _INT32.size and payload: + msg = "bad MSG_EXIT payload size" + raise ImsgProtocolError(msg) + + returncode = 0 + message: str | None = None + if len(payload) >= _INT32.size: + (returncode,) = _INT32.unpack_from(payload) + if len(payload) > _INT32.size: + message = _decode_c_string(payload[_INT32.size :]) or None + return ExitMessage(returncode=returncode, message=message) + + +__all__ = ( + "IMSG_FD_MARK", + "IMSG_HEADER_SIZE", + "MAX_IMSGSIZE", + "ExitMessage", + "MessageType", + "ParsedMessage", + "ProtocolV8Codec", + "RawMessage", + "ReadDoneMessage", + "ReadOpenMessage", + "WriteCloseMessage", + "WriteDataMessage", + "WriteOpenMessage", + "WriteReadyMessage", +) diff --git a/src/libtmux/experimental/engines/registry.py b/src/libtmux/experimental/engines/registry.py new file mode 100644 index 000000000..2809eb356 --- /dev/null +++ b/src/libtmux/experimental/engines/registry.py @@ -0,0 +1,70 @@ +"""A name-keyed registry of engine factories. + +Lets engines be created by name (or :class:`~.base.EngineSpec`) so downstream +code and the contract suite can select a transport without importing its class. +Fails closed on an unknown name. Adapted from the ``libtmux-protocol-engines`` +prototype. +""" + +from __future__ import annotations + +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import EngineKind +from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.control_mode import ControlModeEngine +from libtmux.experimental.engines.subprocess import SubprocessEngine + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import TmuxEngine + +EngineFactory = t.Callable[..., "TmuxEngine"] + +_engine_registry: dict[str, EngineFactory] = {} + + +def register_engine(name: str, factory: EngineFactory) -> None: + """Register an engine factory under a name.""" + _engine_registry[name] = factory + + +def available_engines() -> tuple[str, ...]: + """Return registered engine names, sorted. + + Examples + -------- + >>> from libtmux.experimental.engines import available_engines + >>> "concrete" in available_engines() + True + >>> "subprocess" in available_engines() + True + """ + return tuple(sorted(_engine_registry)) + + +def create_engine(name: str | EngineKind, **kwargs: t.Any) -> TmuxEngine: + """Instantiate a registered engine by name (fail closed). + + Examples + -------- + >>> from libtmux.experimental.engines import create_engine + >>> create_engine("concrete") + + >>> create_engine("nope") + Traceback (most recent call last): + ... + libtmux.exc.LibTmuxException: unknown tmux engine: nope + """ + engine_name = name.value if isinstance(name, EngineKind) else name + try: + factory = _engine_registry[engine_name] + except KeyError as error: + msg = f"unknown tmux engine: {engine_name}" + raise exc.LibTmuxException(msg) from error + return factory(**kwargs) + + +register_engine(EngineKind.SUBPROCESS.value, SubprocessEngine) +register_engine(EngineKind.CONCRETE.value, ConcreteEngine) +register_engine(EngineKind.CONTROL_MODE.value, ControlModeEngine) diff --git a/src/libtmux/experimental/engines/subprocess.py b/src/libtmux/experimental/engines/subprocess.py new file mode 100644 index 000000000..639d829e3 --- /dev/null +++ b/src/libtmux/experimental/engines/subprocess.py @@ -0,0 +1,115 @@ +"""The classic subprocess engine. + +Executes tmux via the CLI binary, one fork per command, mirroring today's +:class:`libtmux.common.tmux_cmd` output handling: ``backslashreplace`` decoding +and trailing-blank stripping. A tmux-side failure is returned as data (nonzero +``returncode`` plus ``stderr``); only a missing binary raises. ``server_args`` +carries the +connection flags (``-L``/``-S``/``-f``/``-2``) so the engine can target a +specific tmux server. +""" + +from __future__ import annotations + +import shutil +import subprocess +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class SubprocessEngine: + """Execute tmux commands by forking the tmux CLI binary. + + Parameters + ---------- + tmux_bin : str or pathlib.Path or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before the command (e.g. + ``("-L", "test")`` or ``("-Lmysocket",)``). + """ + + def __init__( + self, + tmux_bin: str | pathlib.Path | None = None, + *, + server_args: Sequence[str] = (), + ) -> None: + self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self.server_args = tuple(server_args) + self._resolved_bin: str | None = None + + def _resolve_bin(self) -> str: + """Return the tmux binary path, memoized for the engine instance.""" + if self.tmux_bin is not None: + return self.tmux_bin + if self._resolved_bin is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved_bin = resolved + return self._resolved_bin + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command via subprocess and return its result.""" + tmux_bin = request.tmux_bin or self._resolve_bin() + cmd = [tmux_bin, *self.server_args, *request.args] + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="backslashreplace", + ) + stdout, stderr = process.communicate() + returncode = process.returncode + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + + stdout_lines = stdout.split("\n") + while stdout_lines and stdout_lines[-1] == "": + stdout_lines.pop() + stderr_lines = [line for line in stderr.split("\n") if line] + + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout_lines), + stderr=tuple(stderr_lines), + returncode=returncode, + ) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order (subprocess forks per call).""" + return [self.run(req) for req in requests] + + @classmethod + def for_server(cls, server: t.Any) -> SubprocessEngine: + """Build an engine bound to a live :class:`libtmux.Server`'s socket. + + Mirrors :meth:`libtmux.Server.cmd`'s connection-flag construction so the + engine talks to the same tmux server as the object API. + """ + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls(tmux_bin=getattr(server, "tmux_bin", None), server_args=server_args) diff --git a/src/libtmux/experimental/facade/__init__.py b/src/libtmux/experimental/facade/__init__.py new file mode 100644 index 000000000..5d1a62943 --- /dev/null +++ b/src/libtmux/experimental/facade/__init__.py @@ -0,0 +1,51 @@ +"""Engine-typed facades over the operation spine. + +The execution mode lives in the facade *type* (eager vs lazy vs async), so each +method has one statically-known return type, while the operation definitions stay +shared. The matrix over scope x mode: + +========== ============ ============ ============ +scope eager lazy async +========== ============ ============ ============ +server EagerServer LazyServer AsyncServer +session EagerSession LazySession AsyncSession +window EagerWindow LazyWindow AsyncWindow +pane EagerPane LazyPane AsyncPane +client EagerClient LazyClient AsyncClient +========== ============ ============ ============ + +Eager handles execute immediately and return live handles; lazy handles record +into a :class:`~..ops.plan.LazyPlan`; async handles await an +:class:`~..engines.base.AsyncTmuxEngine`. "Control mode" is not a separate family +-- any eager/async facade bound to a ``ControlModeEngine`` already uses it. +""" + +from __future__ import annotations + +from libtmux.experimental.facade.client import AsyncClient, EagerClient, LazyClient +from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane +from libtmux.experimental.facade.server import AsyncServer, EagerServer, LazyServer +from libtmux.experimental.facade.session import ( + AsyncSession, + EagerSession, + LazySession, +) +from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow + +__all__ = ( + "AsyncClient", + "AsyncPane", + "AsyncServer", + "AsyncSession", + "AsyncWindow", + "EagerClient", + "EagerPane", + "EagerServer", + "EagerSession", + "EagerWindow", + "LazyClient", + "LazyPane", + "LazyServer", + "LazySession", + "LazyWindow", +) diff --git a/src/libtmux/experimental/facade/client.py b/src/libtmux/experimental/facade/client.py new file mode 100644 index 000000000..6cd280239 --- /dev/null +++ b/src/libtmux/experimental/facade/client.py @@ -0,0 +1,124 @@ +"""Client-scope facades (eager / lazy / async) over the operation spine. + +A client is a *view* (a terminal attachment keyed by name/tty), not part of the +ownership chain, but tmux exposes client-scoped commands -- ``detach-client``, +``switch-client``, ``refresh-client`` -- so it gets a facade like any other scope. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops import ( + DetachClient, + RefreshClient, + SwitchClient, + arun, + run, +) +from libtmux.experimental.ops._types import ClientName + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class EagerClient: + """A live client handle; methods execute immediately. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> client = EagerClient(ConcreteEngine(), "/dev/pts/3") + >>> client.refresh().ok + True + >>> client.switch_to("$1").ok + True + """ + + engine: TmuxEngine + client_name: str + version: str | None = None + + def detach(self) -> Result: + """Detach this client.""" + return run( + DetachClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + def refresh(self) -> Result: + """Refresh this client.""" + return run( + RefreshClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + def switch_to(self, session_id: str) -> Result: + """Switch this client to a session.""" + return run( + SwitchClient(client=self.client_name, to_session=session_id), + self.engine, + version=self.version, + ) + + +@dataclass(frozen=True) +class LazyClient: + """A deferred client handle; methods record into a plan.""" + + plan: LazyPlan + client_name: str + + def detach(self) -> LazyClient: + """Record a detach; return self for chaining.""" + self.plan.add(DetachClient(target=ClientName(self.client_name))) + return self + + def refresh(self) -> LazyClient: + """Record a refresh; return self for chaining.""" + self.plan.add(RefreshClient(target=ClientName(self.client_name))) + return self + + def switch_to(self, session_id: str) -> LazyClient: + """Record a switch-client; return self for chaining.""" + self.plan.add(SwitchClient(client=self.client_name, to_session=session_id)) + return self + + +@dataclass(frozen=True) +class AsyncClient: + """An async live client handle: the eager client, awaited.""" + + engine: AsyncTmuxEngine + client_name: str + version: str | None = None + + async def detach(self) -> Result: + """Detach this client.""" + return await arun( + DetachClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + async def refresh(self) -> Result: + """Refresh this client.""" + return await arun( + RefreshClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + async def switch_to(self, session_id: str) -> Result: + """Switch this client to a session.""" + return await arun( + SwitchClient(client=self.client_name, to_session=session_id), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/facade/pane.py b/src/libtmux/experimental/facade/pane.py new file mode 100644 index 000000000..61aec8d23 --- /dev/null +++ b/src/libtmux/experimental/facade/pane.py @@ -0,0 +1,234 @@ +"""Pane-scope facades demonstrating "mode lives in the type". + +Two thin facades over the *same* operation spine show why the execution mode +belongs in the class rather than a runtime flag: + +- :class:`EagerPane` executes immediately and returns *live* handles + (``split()`` -> :class:`EagerPane`), so its return types are concrete. +- :class:`LazyPane` records into a :class:`~libtmux.experimental.ops.plan.LazyPlan` + and returns *deferred* handles (``split()`` -> :class:`LazyPane`), executing + only when the plan runs. + +Each ``split()`` therefore has exactly one statically-known return type -- a +single ``Pane`` class with a runtime engine attribute could not express that. +The same :class:`~libtmux.experimental.ops.SplitWindow` operation backs both; +only the facade differs. This is the seed of the wider facade matrix +(``AsyncPane``, ``LazyControlWindow``, ...) described in issue 689. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops import ( + CapturePane, + SendKeys, + SplitWindow, + arun, + run, +) +from libtmux.experimental.ops._types import PaneId + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.results import CapturePaneResult, Result + + +@dataclass(frozen=True) +class EagerPane: + """A live pane handle bound to an engine; methods execute immediately. + + Parameters + ---------- + engine : TmuxEngine + The engine commands run through. + pane_id : str + The concrete tmux pane id (``%N``). + version : str or None + tmux version to render against. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> pane = EagerPane(ConcreteEngine(), "%0") + >>> child = pane.split(horizontal=True) + >>> child.pane_id + '%1' + >>> isinstance(pane.capture().lines, tuple) + True + """ + + engine: TmuxEngine + pane_id: str + version: str | None = None + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> EagerPane: + """Split this pane and return a live handle to the new pane.""" + result = run( + SplitWindow( + target=PaneId(self.pane_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return EagerPane(self.engine, result.new_pane_id, self.version) + + def send_keys(self, keys: str, *, enter: bool = False) -> Result: + """Send keys to this pane; return the typed result.""" + return run( + SendKeys(target=PaneId(self.pane_id), keys=keys, enter=enter), + self.engine, + version=self.version, + ) + + def capture( + self, *, start: int | None = None, end: int | None = None + ) -> CapturePaneResult: + """Capture this pane's contents; return the typed result.""" + return run( + CapturePane(target=PaneId(self.pane_id), start=start, end=end), + self.engine, + version=self.version, + ) + + +@dataclass(frozen=True) +class LazyPane: + """A deferred pane handle; methods record into a plan instead of running. + + Parameters + ---------- + plan : LazyPlan + The plan operations are recorded into. + ref : Target + The target this handle addresses (a concrete id, or a SlotRef for a + pane created earlier in the plan). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> from libtmux.experimental.ops._types import PaneId + >>> plan = LazyPlan() + >>> root = LazyPane(plan, PaneId("%0")) + >>> child = root.split() + >>> _ = child.send_keys("vim", enter=True) + >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome.results[0].new_pane_id + '%1' + >>> outcome.results[1].argv + ('send-keys', '-t', '%1', 'vim', 'Enter') + """ + + plan: LazyPlan + ref: Target + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> LazyPane: + """Record a split; return a deferred handle to the pane it will create.""" + slot = self.plan.add( + SplitWindow( + target=self.ref, + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + ) + return LazyPane(self.plan, slot) + + def send_keys(self, keys: str, *, enter: bool = False) -> LazyPane: + """Record a send-keys against this handle; return self for chaining.""" + self.plan.add(SendKeys(target=self.ref, keys=keys, enter=enter)) + return self + + def capture(self, *, start: int | None = None, end: int | None = None) -> LazyPane: + """Record a capture against this handle; return self for chaining.""" + self.plan.add(CapturePane(target=self.ref, start=start, end=end)) + return self + + +@dataclass(frozen=True) +class AsyncPane: + """An async live pane handle: the eager pane, awaited. + + Identical in shape to :class:`EagerPane` -- same operations, same spine -- + but bound to an :class:`~..engines.base.AsyncTmuxEngine` and awaited. This is + why async is a sibling facade, not a transformation. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> async def main(): + ... pane = AsyncPane(AsyncConcreteEngine(), "%0") + ... child = await pane.split(horizontal=True) + ... return child.pane_id + >>> asyncio.run(main()) + '%1' + """ + + engine: AsyncTmuxEngine + pane_id: str + version: str | None = None + + async def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> AsyncPane: + """Split this pane and return a live async handle to the new pane.""" + result = await arun( + SplitWindow( + target=PaneId(self.pane_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return AsyncPane(self.engine, result.new_pane_id, self.version) + + async def send_keys(self, keys: str, *, enter: bool = False) -> Result: + """Send keys to this pane; return the typed result.""" + return await arun( + SendKeys(target=PaneId(self.pane_id), keys=keys, enter=enter), + self.engine, + version=self.version, + ) + + async def capture( + self, + *, + start: int | None = None, + end: int | None = None, + ) -> CapturePaneResult: + """Capture this pane's contents; return the typed result.""" + return await arun( + CapturePane(target=PaneId(self.pane_id), start=start, end=end), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/facade/server.py b/src/libtmux/experimental/facade/server.py new file mode 100644 index 000000000..b10f6da7d --- /dev/null +++ b/src/libtmux/experimental/facade/server.py @@ -0,0 +1,122 @@ +"""Server-scope facades -- the entry points for facade navigation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.facade.session import ( + AsyncSession, + EagerSession, + LazySession, +) +from libtmux.experimental.ops import NewSession, arun, run + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.plan import LazyPlan + + +@dataclass(frozen=True) +class EagerServer: + """A live server handle; the root of eager facade navigation. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> server = EagerServer(ConcreteEngine()) + >>> session = server.new_session(name="work") + >>> session.session_id + '$1' + >>> pane = session.new_window().split() + >>> pane.pane_id + '%1' + """ + + engine: TmuxEngine + version: str | None = None + + def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> EagerSession: + """Create a detached session; return a live session handle.""" + result = run( + NewSession(session_name=name, start_directory=start_directory), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return EagerSession(self.engine, result.new_id, self.version) + + @classmethod + def for_server(cls, server: t.Any, *, version: str | None = None) -> EagerServer: + """Bind an eager facade to a live :class:`libtmux.Server`'s classic engine.""" + from libtmux.experimental.engines import SubprocessEngine + + return cls(SubprocessEngine.for_server(server), version=version) + + +@dataclass(frozen=True) +class LazyServer: + """A deferred server handle; records session creation into a plan. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> plan = LazyPlan() + >>> server = LazyServer(plan) + >>> session = server.new_session(name="work") + >>> _ = session.new_window(name="build") + >>> plan.execute(ConcreteEngine()).ok + True + """ + + plan: LazyPlan + + def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> LazySession: + """Record a new session; return a deferred session handle.""" + slot = self.plan.add( + NewSession(session_name=name, start_directory=start_directory), + ) + return LazySession(self.plan, slot) + + +@dataclass(frozen=True) +class AsyncServer: + """An async live server handle: the eager server, awaited.""" + + engine: AsyncTmuxEngine + version: str | None = None + + async def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> AsyncSession: + """Create a detached session; return a live async session handle.""" + result = await arun( + NewSession(session_name=name, start_directory=start_directory), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return AsyncSession(self.engine, result.new_id, self.version) + + @classmethod + def for_server(cls, server: t.Any, *, version: str | None = None) -> AsyncServer: + """Bind an async facade to a live :class:`libtmux.Server`'s socket.""" + from libtmux.experimental.engines import AsyncSubprocessEngine + + return cls(AsyncSubprocessEngine.for_server(server), version=version) diff --git a/src/libtmux/experimental/facade/session.py b/src/libtmux/experimental/facade/session.py new file mode 100644 index 000000000..83cc0d40d --- /dev/null +++ b/src/libtmux/experimental/facade/session.py @@ -0,0 +1,166 @@ +"""Session-scope facades (eager / lazy / async) over the operation spine.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow +from libtmux.experimental.ops import ( + KillSession, + NewWindow, + RenameSession, + arun, + run, +) +from libtmux.experimental.ops._types import SessionId + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class EagerSession: + """A live session handle; methods execute immediately. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> session = EagerSession(ConcreteEngine(), "$0") + >>> window = session.new_window(name="build") + >>> window.window_id + '@1' + >>> session.rename("work").ok + True + """ + + engine: TmuxEngine + session_id: str + version: str | None = None + + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> EagerWindow: + """Create a window in this session; return a live window handle.""" + result = run( + NewWindow( + target=SessionId(self.session_id), + name=name, + start_directory=start_directory, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return EagerWindow(self.engine, result.new_id, self.version) + + def rename(self, name: str) -> Result: + """Rename this session.""" + return run( + RenameSession(target=SessionId(self.session_id), name=name), + self.engine, + version=self.version, + ) + + def kill(self) -> Result: + """Kill this session.""" + return run( + KillSession(target=SessionId(self.session_id)), + self.engine, + version=self.version, + ) + + +@dataclass(frozen=True) +class LazySession: + """A deferred session handle; methods record into a plan. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> from libtmux.experimental.ops._types import SessionId + >>> plan = LazyPlan() + >>> session = LazySession(plan, SessionId("$0")) + >>> window = session.new_window(name="build") + >>> _ = session.rename("work") + >>> plan.execute(ConcreteEngine()).ok + True + """ + + plan: LazyPlan + ref: Target + + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> LazyWindow: + """Record a new window; return a deferred window handle.""" + slot = self.plan.add( + NewWindow(target=self.ref, name=name, start_directory=start_directory), + ) + return LazyWindow(self.plan, slot) + + def rename(self, name: str) -> LazySession: + """Record a rename; return self for chaining.""" + self.plan.add(RenameSession(target=self.ref, name=name)) + return self + + def kill(self) -> LazySession: + """Record a kill; return self for chaining.""" + self.plan.add(KillSession(target=self.ref)) + return self + + +@dataclass(frozen=True) +class AsyncSession: + """An async live session handle: the eager session, awaited.""" + + engine: AsyncTmuxEngine + session_id: str + version: str | None = None + + async def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> AsyncWindow: + """Create a window in this session; return a live async window handle.""" + result = await arun( + NewWindow( + target=SessionId(self.session_id), + name=name, + start_directory=start_directory, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return AsyncWindow(self.engine, result.new_id, self.version) + + async def rename(self, name: str) -> Result: + """Rename this session.""" + return await arun( + RenameSession(target=SessionId(self.session_id), name=name), + self.engine, + version=self.version, + ) + + async def kill(self) -> Result: + """Kill this session.""" + return await arun( + KillSession(target=SessionId(self.session_id)), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/facade/window.py b/src/libtmux/experimental/facade/window.py new file mode 100644 index 000000000..32e4ed93a --- /dev/null +++ b/src/libtmux/experimental/facade/window.py @@ -0,0 +1,197 @@ +"""Window-scope facades (eager / lazy / async) over the operation spine. + +Mirrors the pane facades one scope up: an :class:`EagerWindow` executes now and +returns live handles (``split()`` -> :class:`~.pane.EagerPane`), a +:class:`LazyWindow` records into a plan, and an :class:`AsyncWindow` awaits. All +three drive the *same* window-scope operations; only the facade differs. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane +from libtmux.experimental.ops import ( + KillWindow, + RenameWindow, + SelectLayout, + SplitWindow, + arun, + run, +) +from libtmux.experimental.ops._types import WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class EagerWindow: + """A live window handle bound to an engine; methods execute immediately. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> window = EagerWindow(ConcreteEngine(), "@1") + >>> pane = window.split(horizontal=True) + >>> pane.pane_id + '%1' + >>> window.rename("build").ok + True + """ + + engine: TmuxEngine + window_id: str + version: str | None = None + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> EagerPane: + """Split this window's active pane; return a live pane handle.""" + result = run( + SplitWindow( + target=WindowId(self.window_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return EagerPane(self.engine, result.new_pane_id, self.version) + + def rename(self, name: str) -> Result: + """Rename this window.""" + return run( + RenameWindow(target=WindowId(self.window_id), name=name), + self.engine, + version=self.version, + ) + + def select_layout(self, layout: str) -> Result: + """Apply a layout to this window.""" + return run( + SelectLayout(target=WindowId(self.window_id), layout=layout), + self.engine, + version=self.version, + ) + + def kill(self) -> Result: + """Kill this window.""" + return run( + KillWindow(target=WindowId(self.window_id)), + self.engine, + version=self.version, + ) + + +@dataclass(frozen=True) +class LazyWindow: + """A deferred window handle; methods record into a plan. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> from libtmux.experimental.ops._types import WindowId + >>> plan = LazyPlan() + >>> window = LazyWindow(plan, WindowId("@1")) + >>> pane = window.split() + >>> _ = window.rename("build") + >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome.ok + True + """ + + plan: LazyPlan + ref: Target + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> LazyPane: + """Record a split; return a deferred pane handle to the new pane.""" + slot = self.plan.add( + SplitWindow( + target=self.ref, + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + ) + return LazyPane(self.plan, slot) + + def rename(self, name: str) -> LazyWindow: + """Record a rename; return self for chaining.""" + self.plan.add(RenameWindow(target=self.ref, name=name)) + return self + + def select_layout(self, layout: str) -> LazyWindow: + """Record a layout change; return self for chaining.""" + self.plan.add(SelectLayout(target=self.ref, layout=layout)) + return self + + def kill(self) -> LazyWindow: + """Record a kill; return self for chaining.""" + self.plan.add(KillWindow(target=self.ref)) + return self + + +@dataclass(frozen=True) +class AsyncWindow: + """An async live window handle: the eager window, awaited.""" + + engine: AsyncTmuxEngine + window_id: str + version: str | None = None + + async def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> AsyncPane: + """Split this window's active pane; return a live async pane handle.""" + result = await arun( + SplitWindow( + target=WindowId(self.window_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return AsyncPane(self.engine, result.new_pane_id, self.version) + + async def rename(self, name: str) -> Result: + """Rename this window.""" + return await arun( + RenameWindow(target=WindowId(self.window_id), name=name), + self.engine, + version=self.version, + ) + + async def kill(self) -> Result: + """Kill this window.""" + return await arun( + KillWindow(target=WindowId(self.window_id)), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py new file mode 100644 index 000000000..1c1d96ade --- /dev/null +++ b/src/libtmux/experimental/mcp/__init__.py @@ -0,0 +1,295 @@ +"""Framework-agnostic MCP projection: typed, chained, toolable tmux commands. + +The third tier over the Core (ops/plan/engines) and Declarative +(:mod:`libtmux.experimental.workspace`) tiers. It projects each operation into a +typed :class:`~.descriptor.ToolDescriptor` (via +:class:`~.registry.OperationToolRegistry`), resolves agent string/dict targets +(:func:`~.target_resolver.resolve_target`), and exposes plan tools +(:func:`~.plan_tools.preview_plan`, :func:`~.plan_tools.execute_plan`, +:func:`~.plan_tools.result_schema`) plus :func:`~.plan_tools.build_workspace`. + +It has **no** MCP-framework dependency (no fastmcp/pydantic at import time); a +thin adapter in a server (e.g. libtmux-mcp) binds these descriptors at runtime. +Everything here is experimental and outside the versioning policy. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> reg = OperationToolRegistry() +>>> reg.descriptor("new_session").safety +'mutating' +>>> resolve_target("%1") +PaneId(value='%1') +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.mcp.descriptor import ParamDescriptor, ToolDescriptor +from libtmux.experimental.mcp.plan_tools import ( + PlanOutcome, + PlanPreview, + ResultSchema, + aexecute_plan, + build_workspace, + execute_plan, + preview_plan, + result_schema, +) +from libtmux.experimental.mcp.registry import OperationToolRegistry +from libtmux.experimental.mcp.schema import schema_for_type +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary import ( + Listing, + PaneCapture, + PaneResult, + SessionResult, + WindowResult, + capture_pane, + create_session, + create_window, + kill_pane, + kill_session, + kill_window, + list_panes, + list_sessions, + list_windows, + rename_session, + rename_window, + select_layout, + select_pane, + send_input, + split_pane, +) + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from fastmcp import FastMCP + + from libtmux.experimental.mcp.vocabulary._caller import CallerContext + + +def _socket_args( + caller: CallerContext, + *, + socket_path: str | None = None, + socket_name: str | None = None, + no_caller_socket: bool = False, +) -> tuple[str, ...]: + """Resolve tmux connection flags: explicit overrides, else the caller's socket. + + Precedence: ``--socket-path`` > ``--socket-name`` > ``$LIBTMUX_SOCKET_PATH`` > + ``$LIBTMUX_SOCKET`` > the discovered caller's socket (unless suppressed) > + none (ambient/default server). + """ + import os + + from libtmux.experimental.mcp.vocabulary._caller import caller_server_args + + if socket_path: + return ("-S", socket_path) + if socket_name: + return ("-L", socket_name) + env_path = os.environ.get("LIBTMUX_SOCKET_PATH") + if env_path: + return ("-S", env_path) + env_name = os.environ.get("LIBTMUX_SOCKET") + if env_name: + return ("-L", env_name) + if no_caller_socket: + return () + return caller_server_args(caller, explicit=False) + + +def default_server(*, expose_operations: bool = False) -> FastMCP: + """Build a synchronous FastMCP server over a :class:`~..engines.SubprocessEngine`. + + A convenience factory for embedding or deploying the *synchronous* server + with the default tmux socket. Prefer :func:`default_async_server` for the + async-first surface and the live event stream. Requires the ``mcp`` extra + (``pip install 'libtmux[mcp]'``). + """ + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_server + from libtmux.experimental.mcp.vocabulary._caller import CallerContext + + ctx = CallerContext.discover() + engine = SubprocessEngine(server_args=_socket_args(ctx)) + return build_server(engine, expose_operations=expose_operations, caller=ctx) + + +def default_async_server( + *, + expose_operations: bool = False, + events: str = "push", + event_source: str = "subscription", +) -> FastMCP: + """Build the async-first FastMCP server over an :class:`AsyncControlModeEngine`. + + The default deployment: tools are awaited on FastMCP's loop and the live + event stream is wired up. The control-mode connection opens lazily on first + use. Requires the ``mcp`` extra. + """ + import typing as t + + from libtmux.experimental.engines import AsyncControlModeEngine + from libtmux.experimental.mcp.events import EventMode, EventSource + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + from libtmux.experimental.mcp.vocabulary._caller import CallerContext + + ctx = CallerContext.discover() + engine = AsyncControlModeEngine(server_args=_socket_args(ctx)) + return build_async_server( + engine, + caller=ctx, + expose_operations=expose_operations, + events=t.cast("EventMode", events), + event_source=t.cast("EventSource", event_source), + ) + + +def main(argv: Sequence[str] | None = None) -> None: + """Run the libtmux-engine MCP server over stdio (console-script entry). + + Async-first by default (an :class:`AsyncControlModeEngine`); pass ``--sync`` + for the subprocess-backed synchronous server. Event mode/source default from + ``LIBTMUX_MCP_EVENTS`` / ``LIBTMUX_MCP_EVENT_SOURCE``. Wired to the + ``libtmux-engine-mcp`` console script and ``python -m + libtmux.experimental.mcp``. Requires the ``mcp`` extra. + """ + import argparse + import os + import sys + + parser = argparse.ArgumentParser( + prog="libtmux-engine-mcp", + description="Run the experimental libtmux typed-ops MCP server (stdio).", + epilog=( + "socket precedence: --socket-path > --socket-name > " + "$LIBTMUX_SOCKET_PATH > $LIBTMUX_SOCKET > discovered caller socket > " + "default; --no-caller-socket drops the caller socket. " + "caller identity: $TMUX/$TMUX_PANE > $LIBTMUX_MCP_CALLER_PANE " + "(+$LIBTMUX_MCP_CALLER_TMUX) > /proc parent walk " + "($LIBTMUX_MCP_DISCOVER=0 disables)." + ), + ) + parser.add_argument("--name", default="tmux", help="server name") + parser.add_argument( + "--operations", + action="store_true", + help="expose the full per-operation tool surface (op_*)", + ) + parser.add_argument( + "--sync", + action="store_true", + help="use the synchronous subprocess server instead of async-first", + ) + parser.add_argument( + "--events", + choices=("off", "push", "pull", "both"), + default=os.environ.get("LIBTMUX_MCP_EVENTS", "push"), + help="live event mechanism (async server only)", + ) + parser.add_argument( + "--event-source", + choices=("subscription", "output"), + default=os.environ.get("LIBTMUX_MCP_EVENT_SOURCE", "subscription"), + help="event substrate (async server only)", + ) + parser.add_argument( + "--socket-path", + help="tmux -S socket path (overrides caller-socket discovery)", + ) + parser.add_argument( + "--socket-name", + help="tmux -L socket name (overrides caller-socket discovery)", + ) + parser.add_argument( + "--no-caller-socket", + action="store_true", + help="do not auto-bind to the discovered caller's tmux socket", + ) + args = parser.parse_args(argv) + + try: + from libtmux.experimental.mcp.vocabulary._caller import CallerContext + + ctx = CallerContext.discover() + srv_args = _socket_args( + ctx, + socket_path=args.socket_path, + socket_name=args.socket_name, + no_caller_socket=args.no_caller_socket, + ) + if args.sync: + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_server + + server = build_server( + SubprocessEngine(server_args=srv_args), + name=args.name, + expose_operations=args.operations, + caller=ctx, + ) + else: + from libtmux.experimental.engines import AsyncControlModeEngine + from libtmux.experimental.mcp.events import EventMode, EventSource + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + AsyncControlModeEngine(server_args=srv_args), + name=args.name, + expose_operations=args.operations, + events=t.cast("EventMode", args.events), + event_source=t.cast("EventSource", args.event_source), + caller=ctx, + ) + except ImportError: + sys.stderr.write( + "libtmux-engine-mcp requires the 'mcp' extra: pip install 'libtmux[mcp]'\n", + ) + raise SystemExit(1) from None + + server.run(transport="stdio") + + +__all__ = ( + "Listing", + "OperationToolRegistry", + "PaneCapture", + "PaneResult", + "ParamDescriptor", + "PlanOutcome", + "PlanPreview", + "ResultSchema", + "SessionResult", + "ToolDescriptor", + "WindowResult", + "aexecute_plan", + "build_workspace", + "capture_pane", + "create_session", + "create_window", + "default_async_server", + "default_server", + "execute_plan", + "kill_pane", + "kill_session", + "kill_window", + "list_panes", + "list_sessions", + "list_windows", + "main", + "preview_plan", + "rename_session", + "rename_window", + "resolve_target", + "result_schema", + "schema_for_type", + "select_layout", + "select_pane", + "send_input", + "split_pane", +) diff --git a/src/libtmux/experimental/mcp/__main__.py b/src/libtmux/experimental/mcp/__main__.py new file mode 100644 index 000000000..348b9381c --- /dev/null +++ b/src/libtmux/experimental/mcp/__main__.py @@ -0,0 +1,7 @@ +"""Support ``python -m libtmux.experimental.mcp``.""" + +from __future__ import annotations + +from libtmux.experimental.mcp import main + +main() diff --git a/src/libtmux/experimental/mcp/_settle.py b/src/libtmux/experimental/mcp/_settle.py new file mode 100644 index 000000000..71d1d926d --- /dev/null +++ b/src/libtmux/experimental/mcp/_settle.py @@ -0,0 +1,280 @@ +r"""Needle-free settle accumulator for the pane-output monitor. + +A tmux pane stops emitting ``%output`` the instant it stops producing bytes, so +"no ``%output`` for ``settle_ms``" is a direct I/O-layer *quiet* signal -- no +regex, no sentinel injection, no assumed output format. This module is the pure, +framework-free core of that idea: a decoder for tmux's octal ``%output`` +escaping, a per-pane payload filter, and a fold over an injected async stream +that returns the moment the stream goes quiet (or a byte/time cap fires). + +It imports no MCP framework and touches no tmux connection, so every function +here carries an executable doctest driven by literal strings or a fake async +generator with an injected clock. The :mod:`~.events` edge maps a control-mode +engine's ``subscribe()`` stream onto these helpers. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable + +SettleReason = t.Literal["settled", "time_cap", "byte_cap", "stream_end"] + + +@dataclass(frozen=True) +class SettleOutcome: + """Result of folding a decoded ``%output`` stream until the pane settles. + + Parameters + ---------- + text : str + The decoded bytes the pane produced during the watch (tail-preserving + prefix when ``truncated``). + reason : {"settled", "time_cap", "byte_cap", "stream_end"} + Why the fold stopped. ``settled`` means *stopped producing output*, not + *succeeded* -- the caller interprets the text. + byte_count : int + Size of ``text`` in bytes (capped at ``max_bytes``). + frame_count : int + Number of stream chunks folded in. + idle_ms_observed : int + Only meaningful when ``reason == "settled"``: the idle gap (``settle_ms``) + that triggered the stop. For other reasons it is the most recent + inter-chunk gap, or ``0`` if no chunk arrived -- do not read it as the + cause of the stop. + truncated : bool + Whether ``max_bytes`` clipped the text (tail kept). + """ + + text: str + reason: SettleReason + byte_count: int + frame_count: int + idle_ms_observed: int + truncated: bool + + +def decode_output(payload: str) -> str: + r"""Decode tmux's backslash-octal ``%output`` escaping. + + tmux escapes any byte below ``0x20`` and a literal backslash as ``\ooo`` (one + to three octal digits). A backslash not followed by an octal digit -- or a + lone trailing backslash -- passes through verbatim rather than raising. + + Parameters + ---------- + payload : str + The raw ``%output`` data body, after the ``%output %N `` prefix. + + Returns + ------- + str + The decoded text. + + Examples + -------- + A newline and a tab decode from their octal escapes: + + >>> decode_output('a\\012b') + 'a\nb' + >>> decode_output('tab\\011x') + 'tab\tx' + + An escaped backslash collapses to one, and plain text is untouched: + + >>> decode_output('a\\134b') + 'a\\b' + >>> decode_output('plain text, spaces kept') + 'plain text, spaces kept' + + A lone trailing backslash passes through: + + >>> decode_output('trailing\\') + 'trailing\\' + """ + out: list[str] = [] + i, n = 0, len(payload) + while i < n: + ch = payload[i] + if ch == "\\" and i + 1 < n and payload[i + 1] in "01234567": + j = i + 1 + while j < n and j - i <= 3 and payload[j] in "01234567": + j += 1 + out.append(chr(int(payload[i + 1 : j], 8))) + i = j + else: + out.append(ch) + i += 1 + return "".join(out) + + +def output_payload(raw: str, pane_id: str) -> str | None: + r"""Return the decoded ``%output`` payload for *pane_id*, else ``None``. + + Slices the data body with ``raw.split(" ", 2)[2]`` -- **not** + ``" ".join(args[1:])``, which would collapse runs of internal whitespace + because the notification parser split the whole line on single spaces. + + Parameters + ---------- + raw : str + A ``ControlNotification.raw`` line. + pane_id : str + The concrete pane id (``%N``) to match. + + Returns + ------- + str or None + The decoded payload, or ``None`` when *raw* is not an ``%output`` frame + for *pane_id*. + + Examples + -------- + Internal whitespace is preserved exactly: + + >>> output_payload('%output %1 a b', '%1') + 'a b' + + A frame for another pane, or a non-output frame, is ignored: + + >>> output_payload('%output %2 x', '%1') is None + True + >>> output_payload('%window-add @3', '%1') is None + True + """ + parts = raw.split(" ", 2) + if len(parts) < 3 or parts[0] != "%output" or parts[1] != pane_id: + return None + return decode_output(parts[2]) + + +async def accumulate_until_settle( + frames: AsyncGenerator[str, None], + *, + settle_ms: int, + timeout_ms: int, + max_bytes: int, + now: Callable[[], float] = time.monotonic, +) -> SettleOutcome: + r"""Fold a stream of decoded chunks until the pane settles. + + Resets an idle window on each chunk and returns ``reason='settled'`` when no + chunk arrives for ``settle_ms``; ``'byte_cap'`` at ``max_bytes`` (tail + preserved); ``'time_cap'`` when the overall ``timeout_ms`` budget is spent; + ``'stream_end'`` when *frames* is exhausted. The wall-clock budget reads + *now* (inject a scripted clock for deterministic ``time_cap`` tests); the idle + window uses a real :func:`asyncio.wait_for`, so a fake stream that simply + suspends settles deterministically with no scripted sleeps. The stream is + closed via :func:`contextlib.aclosing` on every exit, including cancellation. + + Parameters + ---------- + frames : AsyncGenerator[str, None] + The decoded per-pane output chunks. + settle_ms : int + Idle gap that counts as "settled". + timeout_ms : int + Overall wall-clock budget. + max_bytes : int + Byte cap; the returned text keeps the tail. + now : Callable[[], float] + Monotonic clock source, injectable for tests. + + Returns + ------- + SettleOutcome + The folded text plus the stop reason and counters. + + Examples + -------- + A pane that emits two chunks then goes quiet settles on the idle window: + + >>> import asyncio + >>> async def quiet_after_two(): + ... yield "hello " + ... yield "world" + ... await asyncio.Event().wait() # never another chunk -> idle fires + >>> out = asyncio.run( + ... accumulate_until_settle( + ... quiet_after_two(), settle_ms=10, timeout_ms=1000, max_bytes=4096 + ... ) + ... ) + >>> out.text, out.reason, out.byte_count + ('hello world', 'settled', 11) + + A flood past the byte cap truncates (tail-preserving) and stops: + + >>> async def flood(): + ... for _ in range(100): + ... yield "abcde" + >>> out = asyncio.run( + ... accumulate_until_settle( + ... flood(), settle_ms=50, timeout_ms=1000, max_bytes=8 + ... ) + ... ) + >>> out.reason, out.byte_count, out.truncated + ('byte_cap', 8, True) + + An exhausted stream ends cleanly: + + >>> async def two_then_done(): + ... yield "a" + ... yield "b" + >>> asyncio.run( + ... accumulate_until_settle( + ... two_then_done(), settle_ms=50, timeout_ms=1000, max_bytes=64 + ... ) + ... ).reason + 'stream_end' + """ + buf: list[str] = [] + byte_count = frame_count = 0 + idle_ms_observed = 0 + reason: SettleReason = "stream_end" + settle_s = settle_ms / 1000.0 + deadline = now() + timeout_ms / 1000.0 + async with contextlib.aclosing(frames): + while True: + remaining = deadline - now() + if remaining <= 0: + reason = "time_cap" + break + wait_s = min(settle_s, remaining) + start = now() + try: + chunk = await asyncio.wait_for(frames.__anext__(), timeout=wait_s) + except asyncio.TimeoutError: + if now() - deadline >= 0: # the wall-clock cap, not the idle gap + reason = "time_cap" + else: # idle window elapsed -> the pane went quiet + idle_ms_observed = int(settle_s * 1000) + reason = "settled" + break + except StopAsyncIteration: + reason = "stream_end" + break + idle_ms_observed = int((now() - start) * 1000) + buf.append(chunk) + frame_count += 1 + byte_count += len(chunk.encode()) + if byte_count >= max_bytes: + reason = "byte_cap" + break + text = "".join(buf) + truncated = reason == "byte_cap" + if truncated: # keep the tail -- "did it finish" lives at the end + text = text.encode()[-max_bytes:].decode(errors="replace") + return SettleOutcome( + text=text, + reason=reason, + byte_count=min(byte_count, max_bytes), + frame_count=frame_count, + idle_ms_observed=idle_ms_observed, + truncated=truncated, + ) diff --git a/src/libtmux/experimental/mcp/descriptor.py b/src/libtmux/experimental/mcp/descriptor.py new file mode 100644 index 000000000..929a9c414 --- /dev/null +++ b/src/libtmux/experimental/mcp/descriptor.py @@ -0,0 +1,116 @@ +"""Framework-agnostic typed tool descriptors. + +A :class:`ToolDescriptor` is the projection of one tmux :class:`~..ops.operation. +Operation` into a tool: its name, typed parameters, safety annotations, result +schema, and a :meth:`~ToolDescriptor.build` factory that turns agent-supplied +params into a typed operation (resolving targets). It holds **no** MCP framework +object -- a thin adapter (fastmcp, click, …) binds it at runtime. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.mcp.target_resolver import resolve_target + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops.operation import Operation + +_JSON_TYPES = { + "int": "integer", + "float": "number", + "str": "string", + "bool": "boolean", + "list": "array", + "dict": "object", +} + + +@dataclass(frozen=True, slots=True) +class ParamDescriptor: + """One typed tool parameter, projected from an operation dataclass field.""" + + name: str + origin: str + is_required: bool = True + item_origin: str | None = None + description: str | None = None + version_gate: str | None = None + + def to_json_schema(self) -> dict[str, t.Any]: + """Render this parameter as a JSON-schema fragment. + + Examples + -------- + >>> p = ParamDescriptor("horizontal", "bool", description="split L/R") + >>> p.to_json_schema() + {'type': 'boolean', 'description': 'split L/R'} + """ + schema: dict[str, t.Any] = {"type": _JSON_TYPES.get(self.origin, "string")} + if self.origin == "list": + schema["items"] = { + "type": _JSON_TYPES.get(self.item_origin or "str", "string") + } + if self.description: + schema["description"] = self.description + return schema + + +@dataclass(frozen=True) +class ToolDescriptor: + """A typed tool projected from one operation -- metadata plus a builder. + + Parameters + ---------- + name, title, description + Identity and human text (``name`` is the operation ``kind``). + scope, safety + tmux object scope and the safety tier (drives annotations/tags). + params + Typed parameter descriptors (target/src_target handled by :meth:`build`). + result_type, result_schema + The result class name and a JSON schema for its payload. + annotations, tags + MCP-style hints derived from safety/effects. + operation_cls + The operation class :meth:`build` instantiates. + """ + + name: str + title: str + description: str + scope: str + safety: str + params: Mapping[str, ParamDescriptor] + result_type: str + result_schema: Mapping[str, t.Any] + annotations: Mapping[str, bool] + tags: frozenset[str] + version_gates: Mapping[str, str] + effects: Mapping[str, t.Any] + operation_cls: type[Operation[t.Any]] + + def input_schema(self) -> dict[str, t.Any]: + """Render the JSON schema for this tool's input object.""" + props = {name: param.to_json_schema() for name, param in self.params.items()} + required = [name for name, param in self.params.items() if param.is_required] + schema: dict[str, t.Any] = {"type": "object", "properties": props} + if required: + schema["required"] = required + return schema + + def build(self, **kwargs: t.Any) -> Operation[t.Any]: + """Construct the typed operation from agent params, resolving targets. + + ``target`` / ``src_target`` accept the polymorphic forms + :func:`~.target_resolver.resolve_target` understands; the rest are passed + through as operation fields (an unknown field fails closed via + ``TypeError``). + """ + fields = dict(kwargs) + target = resolve_target(fields.pop("target", None)) + src_target = resolve_target(fields.pop("src_target", None)) + return self.operation_cls(target=target, src_target=src_target, **fields) diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py new file mode 100644 index 000000000..3f290e54f --- /dev/null +++ b/src/libtmux/experimental/mcp/events.py @@ -0,0 +1,538 @@ +"""Live tmux event stream over MCP -- two interchangeable mechanisms (A/B). + +A control-mode engine exposes tmux's asynchronous notifications (``%output``, +``%window-add``, ``%session-changed``, ...) as an ``async for`` stream via +``subscribe()``. FastMCP 3.x has no resource-subscription handshake and buffers a +tool's async generator into one list, so a live stream must be surfaced as +either: + +- **push** -- a long-running ``watch_events`` tool that holds a ``Context`` and + pushes each event as an MCP notification (real-time; best over streamable-http). +- **pull** -- a ``tmux://events`` resource backed by a ring buffer a background + task fills, plus a ``poll_events`` tool; clients poll (stdio-friendly). + +Which is registered is chosen by :func:`register_events` (driven by the +``LIBTMUX_MCP_EVENTS`` env var at the entrypoint). Both consume the engine's +single notification queue, so run one *or* the other per process when comparing. + +The ``source`` axis selects the substrate: ``"output"`` streams raw +notifications; ``"subscription"`` first installs ``refresh-client -B`` format +subscriptions, tmux's debounced, server-side change detection. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import time +import typing as t +from dataclasses import dataclass + +from fastmcp import Context + +from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.mcp._settle import ( + SettleReason, + accumulate_until_settle, + output_payload, +) +from libtmux.experimental.ops import TmuxCommandError + +if t.TYPE_CHECKING: + from collections.abc import AsyncGenerator, AsyncIterator, Sequence + + from fastmcp import FastMCP + + from libtmux.experimental.engines.base import AsyncTmuxEngine, CommandResult + +EventMode = t.Literal["off", "push", "pull", "both"] +EventSource = t.Literal["subscription", "output"] + +_RING_SIZE = 1024 + +# tmux format read once at settle to fill DoneMetadata (tab-joined, one round-trip). +_DONE_FORMAT = "\t".join( + ( + "#{pane_id}", + "#{pane_dead}", + "#{pane_dead_status}", + "#{pane_dead_signal}", + "#{pane_current_command}", + "#{cursor_y}", + "#{history_size}", + "#{pane_in_mode}", + ), +) + + +@dataclass(frozen=True) +class DoneMetadata: + """Needle-free done-heuristics, read once at settle for the agent to interpret. + + A ``pane_dead`` pane with a ``pane_dead_status`` is a *hard* "process exited" + signal; ``pane_current_command`` reverting to a shell is a *soft* "command + finished" signal. The screen-state fields add context without claiming intent. + ``pane_dead`` is ``None`` when the pane is gone or its liveness could not be + read (the command exited and took the pane with it). + """ + + pane_dead: bool | None + pane_dead_status: int | None + pane_dead_signal: str | None + pane_current_command: str | None + cursor_y: int | None + history_size: int | None + pane_in_mode: bool + + +@dataclass(frozen=True) +class MonitorResult: + """What ``wait_for_output`` returns; auto-serialized to structured content. + + ``reason`` is itself a signal: ``settled`` (the pane went quiet -- finished + *or* blocked on input; ``done`` disambiguates), ``time_cap`` (still producing + when the budget ran out; a partial chunk is returned), ``byte_cap`` (flooded, + ``truncated``), ``stream_end`` (the notification stream ended). + ``idle_ms_observed`` is only meaningful when ``reason == "settled"``; + ``snapshot_lines`` is ``None`` when the call passed ``snapshot=False``. + ``exit_code`` is the process exit code when the watched process is known to + have exited (``done.pane_dead`` is true), else ``None``. + """ + + pane_id: str + reason: SettleReason + captured_text: str + byte_count: int + frame_count: int + idle_ms_observed: int + elapsed_ms: int + truncated: bool + dropped: int + done: DoneMetadata + exit_code: int | None + snapshot_lines: tuple[str, ...] | None + + +class _StreamEngine(t.Protocol): + """An async engine that also exposes a ``subscribe()`` notification stream. + + The general :class:`~..engines.base.AsyncTmuxEngine` protocol does not declare + ``subscribe`` (only the control-mode engine has it), so the event tools type + against this narrower protocol after the :func:`_supports_stream` guard. + """ + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command.""" + ... + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute a batch of tmux commands.""" + ... + + def subscribe(self) -> AsyncIterator[t.Any]: + """Yield tmux notifications as they arrive.""" + ... + + +def _supports_stream(engine: AsyncTmuxEngine) -> bool: + """Whether *engine* exposes a ``subscribe()`` notification stream.""" + return callable(getattr(engine, "subscribe", None)) + + +def _event_dict(notification: t.Any) -> dict[str, t.Any]: + """Project a ``ControlNotification`` to a JSON-friendly dict.""" + return { + "kind": notification.kind, + "args": list(notification.args), + "raw": notification.raw, + } + + +async def _install_subscriptions( + engine: _StreamEngine, + specs: list[str] | None, +) -> None: + """Install ``refresh-client -B`` format subscriptions (``name:what:format``).""" + for spec in specs or []: + await engine.run(CommandRequest.from_args("refresh-client", "-B", spec)) + + +class _EventRing: + """A bounded ring buffer fed by a single background ``subscribe()`` reader. + + Each event gets a monotonic sequence number so a ``poll_events`` caller can + ask for "everything since N" without re-reading the whole buffer. + """ + + def __init__(self, engine: _StreamEngine, maxlen: int = _RING_SIZE) -> None: + self._engine = engine + self._buffer: collections.deque[tuple[int, dict[str, t.Any]]] = ( + collections.deque(maxlen=maxlen) + ) + self._seq = 0 + self._task: asyncio.Task[None] | None = None + self._error: str | None = None + + def _ensure_started(self) -> None: + """Start the drainer task once, lazily, on the running loop.""" + if self._task is None: + self._task = asyncio.create_task(self._drain(), name="libtmux-mcp-events") + + async def _drain(self) -> None: + """Copy every notification into the ring buffer (supervised, fail-safe). + + A reader failure is recorded and surfaced on the next :meth:`since` call, + rather than silently freezing the cursor or raising at garbage-collection + time. The subscription is closed deterministically via ``aclosing``. + """ + stream = t.cast("AsyncGenerator[t.Any, None]", self._engine.subscribe()) + try: + async with contextlib.aclosing(stream) as managed: + async for notification in managed: + self._seq += 1 + self._buffer.append((self._seq, _event_dict(notification))) + except Exception as error: # capture: the drainer must never crash + self._error = repr(error) + + def since(self, seq: int) -> dict[str, t.Any]: + """Return buffered events with sequence number greater than *seq*.""" + self._ensure_started() + events = [event for n, event in self._buffer if n > seq] + out: dict[str, t.Any] = {"events": events, "cursor": self._seq} + if self._error is not None: + out["error"] = self._error + return out + + +def register_events( + mcp: FastMCP, + engine: AsyncTmuxEngine, + *, + mode: EventMode = "push", + source: EventSource = "subscription", +) -> None: + """Register the event stream tools/resource on *mcp* per *mode*. + + Does nothing when *mode* is ``"off"`` or *engine* has no ``subscribe()`` + stream (e.g. a subprocess engine) -- the live stream is a control-mode + feature. + """ + if mode == "off" or not _supports_stream(engine): + return + stream = t.cast("_StreamEngine", engine) + if mode in ("push", "both"): + _register_push(mcp, stream, source=source) + if mode in ("pull", "both"): + _register_pull(mcp, stream) + _register_monitor(mcp, stream) + + +def _register_push( + mcp: FastMCP, + engine: _StreamEngine, + *, + source: EventSource, +) -> None: + """Register the long-running ``watch_events`` push tool.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + async def watch_events( + ctx: Context, + kinds: list[str] | None = None, + max_events: int = 20, + timeout: float = 30.0, + subscriptions: list[str] | None = None, + ) -> dict[str, t.Any]: + """Stream live tmux notifications, pushing each as an MCP log message. + + Returns after *max_events* notifications or *timeout* seconds, whichever + comes first. ``kinds`` filters by notification kind (e.g. ``window-add``, + ``output``). With ``source="subscription"``, pass ``subscriptions`` as + ``name:what:format`` specs to install ``refresh-client -B`` watches first. + """ + if source == "subscription": + await _install_subscriptions(engine, subscriptions) + collected: list[dict[str, t.Any]] = [] + + async def _collect() -> None: + async for notification in engine.subscribe(): + if kinds and notification.kind not in kinds: + continue + await ctx.info(notification.raw) + collected.append(_event_dict(notification)) + if max_events and len(collected) >= max_events: + return + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(_collect(), timeout=timeout) + return {"events": collected, "count": len(collected)} + + tool = FunctionTool.from_function( + watch_events, + name="watch_events", + description="Stream live tmux notifications as MCP messages", + tags={"readonly", "events"}, + annotations=ToolAnnotations(title="watch_events", readOnlyHint=True), + ) + mcp.add_tool(tool) + + +def _register_pull(mcp: FastMCP, engine: _StreamEngine) -> None: + """Register the ``tmux://events`` resource + ``poll_events`` pull tool.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + ring = _EventRing(engine) + + async def read_events() -> dict[str, t.Any]: + """Return all buffered tmux events (starts the reader on first read).""" + return ring.since(0) + + mcp.resource( + "tmux://events", + name="tmux-events", + description="Buffered tmux control-mode notifications", + )(read_events) + + async def poll_events(since: int = 0) -> dict[str, t.Any]: + """Return tmux events with sequence number greater than *since*. + + The response ``cursor`` is the latest sequence number; pass it back as + ``since`` next call to receive only newer events. + """ + return ring.since(since) + + tool = FunctionTool.from_function( + poll_events, + name="poll_events", + description="Poll buffered tmux events since a cursor", + tags={"readonly", "events"}, + annotations=ToolAnnotations(title="poll_events", readOnlyHint=True), + ) + mcp.add_tool(tool) + + +def _gone_done() -> DoneMetadata: + """``DoneMetadata`` for a pane that is gone or whose liveness can't be read.""" + return DoneMetadata( + pane_dead=None, + pane_dead_status=None, + pane_dead_signal=None, + pane_current_command=None, + cursor_y=None, + history_size=None, + pane_in_mode=False, + ) + + +async def _read_done(engine: _StreamEngine, pane_id: str) -> DoneMetadata: + """Fill :class:`DoneMetadata` for *pane_id* in one ``display-message`` read. + + Fail-safe: when the pane is gone -- the probe errors, returns blank, or tmux + resolves a *different* (fallback) pane -- liveness is reported as unknown + (``pane_dead=None``) rather than raising or fabricating ``pane_dead=False``, + so a command that exited and took its pane still yields a result. + """ + from libtmux.experimental.mcp.vocabulary.server import adisplay_message + + try: + text = (await adisplay_message(engine, pane_id, _DONE_FORMAT)).text + except TmuxCommandError: + return _gone_done() + fields = (text.split("\t") + [""] * 8)[:8] + if not fields[0].strip() or fields[0].strip() != pane_id: + return _gone_done() # blank probe, or tmux resolved a fallback pane + + def _as_int(value: str) -> int | None: + value = value.strip() + if not value: + return None + try: + return int(value) + except ValueError: + return None + + def _as_str(value: str) -> str | None: + return value.strip() or None + + return DoneMetadata( + pane_dead=fields[1].strip() == "1", + pane_dead_status=_as_int(fields[2]), + pane_dead_signal=_as_str(fields[3]), + pane_current_command=_as_str(fields[4]), + cursor_y=_as_int(fields[5]), + history_size=_as_int(fields[6]), + pane_in_mode=fields[7].strip() == "1", + ) + + +async def _ensure_attached(engine: _StreamEngine, session_id: str) -> None: + """Attach the control client to *session_id* so its panes emit ``%output``. + + A bare ``tmux -C`` control client receives **no** ``%output`` until it + attaches to a session (a server-global notification like ``%window-add`` + arrives without attaching, but per-pane output does not). Attaching also + triggers a one-time screen redraw, so a *successful* attachment is tracked + per engine: re-watching the same session does not re-attach or redraw again. + + Raises on a failed attach (stale or killed session) instead of caching, so + the caller gets a clear error rather than a silently empty capture and a + later call can retry. + """ + if getattr(engine, "_attached_session", None) == session_id: + return + result = await engine.run( + CommandRequest.from_args("attach-session", "-t", session_id), + ) + if result.returncode != 0: + detail = " ".join(result.stderr) or "attach-session failed" + msg = f"cannot watch {session_id}: {detail}" + raise RuntimeError(msg) + engine._attached_session = session_id # type: ignore[attr-defined] + + +def _register_monitor(mcp: FastMCP, engine: _StreamEngine) -> None: + """Register the ``wait_for_output`` needle-free settle monitor tool.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + async def wait_for_output( + ctx: Context, + target: str, + settle_ms: int = 750, + timeout: float = 30.0, + max_bytes: int = 131072, + stream_partials: bool = False, + snapshot: bool = True, + ) -> MonitorResult: + """Run a command and wait for it to finish; watch a pane until it settles. + + Use this to run a long-running command -- a test run (``uv run pytest``), a + build, an install, a server coming up -- and wait for the result instead of + polling with sleep + capture_pane. Typical flow: ``send_input`` the command + to a pane (``enter=True``), then call ``wait_for_output`` on that same pane. + It folds the bytes the pane *produces* and returns the instant it stays idle + for ``settle_ms`` -- or ``timeout`` / ``max_bytes`` fires, or the stream + ends. Needle-free: no regex, no sentinel injection. + + **Settled is not success.** ``reason='settled'`` means the pane stopped + producing output -- it cannot, on its own, tell "finished, back to the + shell" from "blocked waiting on stdin". To confirm the command exited, read + ``done.pane_dead`` with ``done.pane_dead_status`` (the process exit code; 0 + is success) and ``done.pane_current_command`` (a shell name means idle). + ``dropped`` / ``truncated`` warn the captured chunk may be incomplete. + + While it runs it shares tmux's single ``%output`` stream with + ``watch_events`` / ``poll_events``; it is bounded by the caps and + short-lived, and each call runs in its own task so it does not block other + tools. The first watch on a session attaches the control client (so its + panes emit ``%output``), which draws the current screen once into + ``captured_text`` -- the clean rendered grid, when requested, is in + ``snapshot_lines``. + + Parameters + ---------- + target : str + The pane to watch: a tmux id (``%pane``, ``@window``, ``$session``), a + name, or ``session:window.pane``. Resolve directional specials to a + concrete ``%N`` first. + settle_ms : int + Idle time in milliseconds with no new output before the pane is treated + as done (default 750). Lower returns sooner but risks a false settle + while a command pauses; raise it for chatty or bursty commands. + timeout : float + Wall-clock seconds to wait before giving up (default 30.0). Raise it for + slow test suites or heavy builds; on expiry ``reason`` reports the cap. + max_bytes : int + Cap on captured output bytes (default 131072). On overflow the watch + returns early with ``truncated`` set; raise it to keep more output. + stream_partials : bool + When ``True``, also push each output chunk live as an MCP log message + for real-time progress on long runs (default ``False``). + snapshot : bool + When ``True`` (default), capture the rendered pane grid into + ``snapshot_lines`` at settle; ``False`` skips that extra capture and + leaves ``snapshot_lines`` ``None``. + """ + from libtmux.experimental.mcp.target_resolver import resolve_target + from libtmux.experimental.mcp.vocabulary._resolve import ( + pane_id as resolve_pane_id, + reject_relative_special, + session_id_of, + ) + from libtmux.experimental.mcp.vocabulary.pane import acapture_pane + + reject_relative_special(resolve_target(target)) + pane = await resolve_pane_id(engine, target, None) + await _ensure_attached(engine, await session_id_of(engine, target, None)) + + dropped_before = getattr(engine, "dropped_notifications", 0) + started = time.monotonic() + + async def _frames() -> AsyncGenerator[str, None]: + async for notification in engine.subscribe(): + payload = output_payload(notification.raw, pane) + if payload is None: + continue + if stream_partials: + await ctx.info(payload) + yield payload + + outcome = await accumulate_until_settle( + _frames(), + settle_ms=settle_ms, + timeout_ms=int(timeout * 1000), + max_bytes=max_bytes, + ) + elapsed_ms = int((time.monotonic() - started) * 1000) + dropped = getattr(engine, "dropped_notifications", 0) - dropped_before + + done = await _read_done(engine, pane) + snapshot_lines: tuple[str, ...] | None = None + if snapshot: + # A pane that died at settle cannot be captured -- keep the result. + with contextlib.suppress(TmuxCommandError): + captured = await acapture_pane( + engine, + pane, + join_wrapped=True, + trim_trailing=True, + ) + snapshot_lines = tuple(captured.lines) + + return MonitorResult( + pane_id=pane, + reason=outcome.reason, + captured_text=outcome.text, + byte_count=outcome.byte_count, + frame_count=outcome.frame_count, + idle_ms_observed=outcome.idle_ms_observed, + elapsed_ms=elapsed_ms, + truncated=outcome.truncated, + dropped=dropped, + done=done, + exit_code=done.pane_dead_status if done.pane_dead else None, + snapshot_lines=snapshot_lines, + ) + + tool = FunctionTool.from_function( + wait_for_output, + name="wait_for_output", + description=( + "Run a command and wait for it to finish (command completion): watch " + "one pane's live output and return when it goes quiet (settles). Use " + "after send_input to wait for long-running tests/builds/installs " + "instead of sleep + capture_pane polling. Needle-free (no " + "regex/sentinel); read captured_text and the done metadata (pane_dead, " + "pane_dead_status = exit / return code, 0 is success) to tell whether " + "it finished or failed." + ), + tags={"readonly", "events", "monitor"}, + annotations=ToolAnnotations(title="wait_for_output", readOnlyHint=True), + ) + mcp.add_tool(tool) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py new file mode 100644 index 000000000..915f25511 --- /dev/null +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -0,0 +1,646 @@ +"""Optional fastmcp adapter -- expose the typed projection on a FastMCP server. + +This is the thin, framework-specific edge. It requires the ``mcp`` extra +(``pip install libtmux[mcp]``); fastmcp is imported lazily so the rest of +:mod:`libtmux.experimental.mcp` stays dependency-free. + +The vocabulary is **async-first**: :func:`build_async_server` registers the +``async def`` tools so FastMCP awaits them directly on its event loop (the right +fit for the persistent control-mode connection's loop affinity), and adds the +live event stream. :func:`build_server` is the synchronous wrapper -- it +registers the derived sync twins, which FastMCP offloads to a worker thread. + +Both project the same three tool layers over one engine: + +1. **Curated vocabulary** -- the intuitive, hand-written tools + (:mod:`~libtmux.experimental.mcp.vocabulary`), always visible. +2. **Per-operation tools** -- one ``op_`` per registered operation, hidden + behind the ``per-op`` tag by default (the full surface is large). +3. **Plan tools** -- compose and run a whole :class:`~..ops.plan.LazyPlan`. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import typing as t + +from libtmux.experimental.mcp import vocabulary +from libtmux.experimental.mcp.registry import OperationToolRegistry +from libtmux.experimental.mcp.vocabulary._caller import CallerContext + +if t.TYPE_CHECKING: + from collections.abc import Callable + + from fastmcp import FastMCP + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.mcp.descriptor import ToolDescriptor + from libtmux.experimental.mcp.events import EventMode, EventSource + +# (public tool name, safety tier). The async tool is ``a`` and the sync +# twin is ```` -- a single table drives both surfaces. +_TOOLS: tuple[tuple[str, str], ...] = ( + ("create_session", "mutating"), + ("create_window", "mutating"), + ("split_pane", "mutating"), + ("send_input", "mutating"), + ("capture_pane", "readonly"), + ("capture_active_pane", "readonly"), + ("grep_pane", "readonly"), + ("list_sessions", "readonly"), + ("list_windows", "readonly"), + ("list_panes", "readonly"), + ("list_clients", "readonly"), + ("has_session", "readonly"), + ("show_options", "readonly"), + ("show_buffer", "readonly"), + ("display_message", "readonly"), + ("resolve_relative_pane", "readonly"), + ("capture_relative_pane", "readonly"), + ("grep_relative_pane", "readonly"), + ("search_panes", "readonly"), + ("find_pane_by_position", "readonly"), + ("rename_window", "mutating"), + ("rename_session", "mutating"), + ("select_window", "mutating"), + ("select_layout", "mutating"), + ("select_pane", "mutating"), + ("move_window", "mutating"), + ("swap_window", "mutating"), + ("resize_pane", "mutating"), + ("swap_pane", "mutating"), + ("join_pane", "mutating"), + ("break_pane", "mutating"), + ("respawn_pane", "mutating"), + ("set_option", "mutating"), + ("set_buffer", "mutating"), + ("paste_buffer", "mutating"), + ("run_tmux", "mutating"), + ("kill_pane", "destructive"), + ("kill_window", "destructive"), + ("kill_session", "destructive"), +) + +# Read-only discovery anchors -- the tools an agent should reach for first. +# Tagged with vendor metadata best-effort (fastmcp 3.4.2 passes ``_meta`` through +# but assigns it no semantics, so this is advisory only). +_ANCHORS = frozenset( + { + "list_panes", + "search_panes", + "grep_relative_pane", + "capture_active_pane", + "get_caller_context", + }, +) + +# Fail loud at import if an anchor name drifts from the registered tool set +# (the alwaysLoad metadata is opaque, so a typo would otherwise fail silently). +_unknown_anchors = _ANCHORS - ({name for name, _ in _TOOLS} | {"get_caller_context"}) +if _unknown_anchors: # pragma: no cover - import-time guard + _msg = f"unknown anchor tools: {sorted(_unknown_anchors)}" + raise RuntimeError(_msg) + +_TARGET_HELP = ( + "tmux target: an id (%pane, @window, $session), a name, or 'session:window.pane'" +) + + +def _agent_context_segment(ctx: CallerContext) -> str: + """Return the agent-context paragraph naming the caller's pane.""" + if ctx.in_tmux and ctx.pane_id: + socket = ctx.socket_path or "default" + session = f" (session {ctx.session_id})" if ctx.session_id else "" + return ( + f"Agent context: this MCP runs from pane {ctx.pane_id} on socket " + f'{socket}{session}. That pane is flagged is_caller ("1" in ' + "list_panes rows, true in search_panes matches) -- call " + "get_caller_context to read it. " + "Omitting a target/origin on the caller-aware tools " + "(resolve_relative_pane/capture_relative_pane/grep_relative_pane) " + "means YOUR pane." + ) + return ( + "Agent context: this MCP is not running inside a tmux pane, so there is no " + "caller pane and no row is flagged is_caller; the relative tools " + "(resolve_relative_pane/capture_relative_pane/grep_relative_pane) require an " + "explicit origin pane id here." + ) + + +def _instructions(ctx: CallerContext, *, events_enabled: bool = False) -> str: + """Compose the server instructions, woven with the live caller context. + + *events_enabled* gates the live-output guidance (``wait_for_output`` / + ``watch_events``), which is registered only on a streaming control-mode + server -- the sync server omits it so it never names a tool it lacks. + """ + closer = ( + "The curated tools cover most needs; the per-operation surface (op_*) " + "and the plan tools (preview_plan/execute_plan/result_schema/" + "build_workspace) are power-use." + ) + if events_enabled: + closer += ( + " For live output: wait_for_output waits for one pane to settle " + "(run-a-command-and-wait); watch_events/poll_events stream/buffer raw " + "control-mode notifications across the server." + ) + segments = [ + "This MCP drives a real tmux server through typed tools: sessions, " + "windows, panes, terminal scrollback, send-keys, copy-mode buffers. " + "Targets accept tmux ids (%pane, @window, $session), names, or " + "'session:window.pane'.", + "When to invoke: managing tmux panes/windows/sessions; reading " + "terminal scrollback (capture_pane/grep_pane/search_panes); sending " + "keystrokes to a running shell or REPL (send_input); copy-mode and " + "paste-buffer work; operating on a pane relative to another or to you " + "(capture_relative_pane/grep_relative_pane).", + "Do NOT invoke for: editor panes you edit via file tools; browser tabs " + "or web content; GUI application windows; notebook cells; any non-tmux " + "terminal surface. tmux only sees terminal panes -- it cannot read a " + "browser or GUI app.", + "Prefer a concrete %N pane id; resolve relative or caller-relative " + "targets to a concrete %N before capture/send. Never hand a directional " + "special target ({up-of}/{down-of}/{left-of}/{right-of}) to " + "capture_pane/grep_pane/send_input -- those resolve against THIS MCP's " + "control client, not your pane; use capture_relative_pane / " + "grep_relative_pane / resolve_relative_pane instead.", + _agent_context_segment(ctx), + ] + if events_enabled: + segments.append( + "Run a command and wait for it to finish / for completion " + "(long-running builds, test runs like `uv run pytest`, installs, a " + "server reaching ready): split_pane or pick a pane, send_input the " + "command (enter=True), then call wait_for_output on that same pane -- " + "it folds the live output and returns when the pane goes quiet " + "(settles), needle-free (no regex, no sentinel). Prefer this over " + "polling with sleep + capture_pane: wait_for_output is event-backed, " + "returns the captured_text, and reports done.pane_dead / " + "done.pane_dead_status (process exit / return code) plus " + "done.pane_current_command so you can tell finished from " + "blocked-on-input. Settled means output stopped, not that the command " + "succeeded or failed -- read the done metadata to confirm exit status.", + ) + segments.append( + "list_panes/list_windows/show_options query tmux metadata (format " + "fields); grep_pane (one pane) and search_panes (across panes) search " + "terminal text (scrollback). Pick the right one for 'which pane shows X'.", + ) + segments.append(closer) + return "\n\n".join(segments) + + +def _summary(doc: str | None) -> str | None: + """Return the first non-empty docstring line.""" + for line in (doc or "").splitlines(): + if line.strip(): + return line.strip() + return None + + +def _bind_engine( + fn: Callable[..., t.Any], + engine: TmuxEngine | AsyncTmuxEngine, + *, + is_async: bool, +) -> Callable[..., t.Any]: + """Bind *engine* out of *fn*, returning a wrapper fastmcp can introspect. + + Carries *pre-resolved* annotations (with ``engine`` removed) and an explicit + ``__signature__`` so fastmcp's ``get_type_hints`` never re-evaluates the + forward references against the wrong module globals. The async branch returns + a coroutine function so FastMCP awaits it on the loop; the sync branch a plain + function (offloaded to a thread). + """ + hints = t.get_type_hints(fn) + signature = inspect.signature(fn) + params = [p for name, p in signature.parameters.items() if name != "engine"] + + async def _async_tool(*args: t.Any, **kwargs: t.Any) -> t.Any: + return await fn(engine, *args, **kwargs) + + def _sync_tool(*args: t.Any, **kwargs: t.Any) -> t.Any: + return fn(engine, *args, **kwargs) + + # Typed Any so the dunder rebinds below are not checked against a plain + # Callable (which carries no __name__/__signature__ in mypy's view). + tool: t.Any = _async_tool if is_async else _sync_tool + tool.__name__ = fn.__name__ + tool.__qualname__ = fn.__name__ + tool.__doc__ = fn.__doc__ + tool.__signature__ = signature.replace(parameters=params) + tool.__annotations__ = {k: v for k, v in hints.items() if k != "engine"} + return t.cast("Callable[..., t.Any]", tool) + + +def register_vocabulary( + mcp: FastMCP, + engine: TmuxEngine | AsyncTmuxEngine, + *, + is_async: bool, +) -> None: + """Register the curated vocabulary as tools on *mcp*, bound to *engine*.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + for name, safety in _TOOLS: + fn = getattr(vocabulary, ("a" + name) if is_async else name) + annotations = ToolAnnotations( + title=name, + readOnlyHint=safety == "readonly", + destructiveHint=safety == "destructive", + ) + tool = FunctionTool.from_function( + _bind_engine(fn, engine, is_async=is_async), + name=name, + description=_summary(fn.__doc__), + tags={safety}, + annotations=annotations, + meta={"anthropic/alwaysLoad": True} if name in _ANCHORS else None, + ) + mcp.add_tool(tool) + + +def register_caller_context(mcp: FastMCP, ctx: CallerContext) -> None: + """Register the ``get_caller_context`` anchor returning the build-time context. + + It closes over the context read once from the server's environment -- it must + *not* re-query tmux, which would answer for the control client, not the + caller. + """ + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + def get_caller_context() -> CallerContext: + """Return the tmux pane/server discovered for this MCP. + + From the server's own env, an explicit override, or a bounded ``/proc`` + parent walk -- inspect the ``source`` field for which. + """ + return ctx + + tool = FunctionTool.from_function( + get_caller_context, + name="get_caller_context", + description=_summary(get_caller_context.__doc__), + tags={"readonly"}, + annotations=ToolAnnotations(title="get_caller_context", readOnlyHint=True), + meta=( + {"anthropic/alwaysLoad": True} if "get_caller_context" in _ANCHORS else None + ), + ) + mcp.add_tool(tool) + + +def _op_input_schema(descriptor: ToolDescriptor) -> dict[str, t.Any]: + """Return the per-op tool's input schema, re-adding the target params. + + The :class:`~..registry.OperationToolRegistry` omits ``target`` / + ``src_target`` from a descriptor's params (they are polymorphic + :data:`~..ops._types.Target` values), so the schema is re-completed here -- + at the framework edge -- as plain ``string`` params. + """ + schema = descriptor.input_schema() + fields = { + field.name: field for field in dataclasses.fields(descriptor.operation_cls) + } + target_props: dict[str, t.Any] = {} + required = list(schema.get("required", [])) + for name in ("target", "src_target"): + field = fields.get(name) + if field is None: + continue + target_props[name] = {"type": "string", "description": _TARGET_HELP} + is_required = ( + field.default is dataclasses.MISSING + and field.default_factory is dataclasses.MISSING + ) + if is_required and name not in required: + required.append(name) + if target_props: + schema["properties"] = {**target_props, **schema["properties"]} + if required: + schema["required"] = required + return schema + + +def register_operations( + mcp: FastMCP, + engine: TmuxEngine | AsyncTmuxEngine, + *, + is_async: bool, + registry: OperationToolRegistry | None = None, + hidden: bool = True, +) -> None: + """Register one ``op_`` tool per registered operation. + + Each tool carries the operation's precomputed JSON schema and dispatches to + :meth:`~..descriptor.ToolDescriptor.build` + :func:`~..ops.execute.run` (or + :func:`~..ops.execute.arun` for an async engine), returning the serialized + result. Tools are tagged ``per-op`` plus their safety tier; when *hidden* + (the default) the ``per-op`` tag is disabled. + """ + from fastmcp.tools import Tool, ToolResult + from mcp.types import ToolAnnotations + from pydantic import PrivateAttr + + from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + from libtmux.experimental.mcp.vocabulary._resolve import guard_destructive_op + from libtmux.experimental.ops import arun as arun_op, run as run_op + from libtmux.experimental.ops.serialize import result_to_dict + + class _OperationTool(Tool): + """A per-operation tool: explicit schema + dispatch to the registry.""" + + _descriptor: t.Any = PrivateAttr(default=None) + _engine: t.Any = PrivateAttr(default=None) + _is_async: bool = PrivateAttr(default=False) + + async def run(self, arguments: dict[str, t.Any]) -> ToolResult: + operation = self._descriptor.build(**arguments) + # The per-op surface dispatches around the curated guard, so apply the + # self-kill guard here too (a sync engine is wrapped to async for it). + guard_engine = ( + self._engine if self._is_async else SyncToAsyncEngine(self._engine) + ) + await guard_destructive_op(guard_engine, operation) + if self._is_async: + result = await arun_op(operation, self._engine) + else: + result = run_op(operation, self._engine) + return ToolResult( + structured_content=result_to_dict(result), + is_error=not result.ok, + ) + + reg = registry if registry is not None else OperationToolRegistry() + for descriptor in reg.descriptors(): + annotations = ToolAnnotations( + title=descriptor.title, + readOnlyHint=descriptor.safety == "readonly", + destructiveHint=descriptor.safety == "destructive", + ) + tool = _OperationTool( + name=f"op_{descriptor.name}", + description=descriptor.description or None, + parameters=_op_input_schema(descriptor), + tags={*descriptor.tags, "per-op"}, + annotations=annotations, + ) + tool._descriptor = descriptor + tool._engine = engine + tool._is_async = is_async + mcp.add_tool(tool) + if hidden: + mcp.disable(tags={"per-op"}) + + +def register_plan_tools( + mcp: FastMCP, + engine: TmuxEngine | AsyncTmuxEngine, + *, + is_async: bool, + registry: OperationToolRegistry | None = None, +) -> None: + """Register the plan-tier tools (compose + run serialized :class:`LazyPlan`s). + + ``preview_plan`` / ``result_schema`` are pure; ``execute_plan`` runs a + serialized plan (via :func:`~..mcp.plan_tools.aexecute_plan` on an async + engine). ``build_workspace`` is registered only on the synchronous server + (the declarative runner is synchronous). + """ + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + from libtmux.experimental.mcp import plan_tools as _plan + from libtmux.experimental.ops import LazyPlan + from libtmux.experimental.ops.planner import ( + FoldingPlanner, + MarkedPlanner, + Planner, + SequentialPlanner, + ) + from libtmux.experimental.ops.serialize import operation_from_dict + + reg = registry if registry is not None else OperationToolRegistry() + planners: dict[str, type[Planner]] = { + "sequential": SequentialPlanner, + "folding": FoldingPlanner, + "marked": MarkedPlanner, + } + + def _plan_from_dicts(operations: list[dict[str, t.Any]]) -> LazyPlan: + plan = LazyPlan() + for data in operations: + plan.add(operation_from_dict(data)) + return plan + + def _planner(name: str) -> Planner: + chosen = planners.get(name) + if chosen is None: + msg = f"unknown planner {name!r}; choose from {sorted(planners)}" + raise ValueError(msg) + return chosen() + + def preview_plan( + operations: list[dict[str, t.Any]], + version: str | None = None, + ) -> dict[str, t.Any]: + """Render a serialized plan without executing it (refs render as null).""" + preview = _plan.preview_plan(_plan_from_dicts(operations), version=version) + return { + "ok": preview.ok, + "operations": preview.operations, + "argv": [list(item) if item is not None else None for item in preview.argv], + } + + def result_schema(kind: str) -> dict[str, t.Any]: + """Report what an operation kind returns, for planning forward refs.""" + schema = _plan.result_schema(reg, kind) + return { + "kind": schema.kind, + "result_type": schema.result_type, + "schema": schema.schema, + "binding_fields": schema.binding_fields, + } + + tools: list[tuple[Callable[..., t.Any], str]] = [ + (preview_plan, "readonly"), + (result_schema, "readonly"), + ] + + if is_async: + + async def execute_plan( + operations: list[dict[str, t.Any]], + planner: str = "sequential", + version: str | None = None, + ) -> dict[str, t.Any]: + """Execute a serialized plan over the engine; return results + bindings.""" + outcome = await _plan.aexecute_plan( + _plan_from_dicts(operations), + t.cast("AsyncTmuxEngine", engine), + version=version, + planner=_planner(planner), + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + tools.append((execute_plan, "mutating")) + else: + + def execute_plan( # type: ignore[misc] + operations: list[dict[str, t.Any]], + planner: str = "sequential", + version: str | None = None, + ) -> dict[str, t.Any]: + """Execute a serialized plan over the engine; return results + bindings.""" + outcome = _plan.execute_plan( + _plan_from_dicts(operations), + t.cast("TmuxEngine", engine), + version=version, + planner=_planner(planner), + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + def build_workspace( + spec: dict[str, t.Any], + preflight: bool = True, + version: str | None = None, + ) -> dict[str, t.Any]: + """Build a declarative workspace (the Declarative tier) in one call.""" + outcome = _plan.build_workspace( + spec, + t.cast("TmuxEngine", engine), + version=version, + preflight=preflight, + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + tools.append((execute_plan, "mutating")) + tools.append((build_workspace, "mutating")) + + for fn, safety in tools: + annotations = ToolAnnotations( + title=fn.__name__, + readOnlyHint=safety == "readonly", + destructiveHint=False, + ) + tool = FunctionTool.from_function( + fn, + name=fn.__name__, + description=_summary(fn.__doc__), + tags={"plan", safety}, + annotations=annotations, + ) + mcp.add_tool(tool) + + +def _stash_caller(engine: t.Any, ctx: CallerContext) -> None: + """Stash the discovered caller on the engine so the tool bodies can read it. + + The curated tool bodies bind only ``engine``; stashing the once-discovered + context here (read by :func:`~.vocabulary._resolve.caller_of`) threads caller + identity to them without changing every tool signature. + """ + engine._caller_context = ctx + + +def build_server( + engine: TmuxEngine, + *, + name: str = "tmux", + instructions: str | None = None, + include_operations: bool = True, + expose_operations: bool = False, + include_plan_tools: bool = True, + caller: CallerContext | None = None, +) -> FastMCP: + """Build a synchronous FastMCP server over a sync *engine*. + + The sync wrapper: the curated tools are the derived sync twins, which FastMCP + offloads to a worker thread. Prefer :func:`build_async_server` for the + async-first surface and the event stream. *caller* defaults to + :meth:`CallerContext.discover`. + """ + from fastmcp import FastMCP + + ctx = caller if caller is not None else CallerContext.discover() + _stash_caller(engine, ctx) + mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) + registry = OperationToolRegistry() + register_vocabulary(mcp, engine, is_async=False) + register_caller_context(mcp, ctx) + if include_operations: + register_operations( + mcp, + engine, + is_async=False, + registry=registry, + hidden=not expose_operations, + ) + if include_plan_tools: + register_plan_tools(mcp, engine, is_async=False, registry=registry) + return mcp + + +def build_async_server( + engine: AsyncTmuxEngine, + *, + name: str = "tmux", + instructions: str | None = None, + include_operations: bool = True, + expose_operations: bool = False, + include_plan_tools: bool = True, + events: EventMode = "push", + event_source: EventSource = "subscription", + caller: CallerContext | None = None, +) -> FastMCP: + """Build the async-first FastMCP server over an async *engine*. + + The curated tools and per-op/plan tools are registered as ``async`` and + awaited directly on FastMCP's event loop. When *engine* supports a + notification stream (a control-mode engine), the live event tools are + registered per *events* (``"push"``/``"pull"``/``"both"``/``"off"``). + *caller* defaults to :meth:`CallerContext.discover`. + """ + from fastmcp import FastMCP + + from libtmux.experimental.mcp.events import _supports_stream, register_events + + ctx = caller if caller is not None else CallerContext.discover() + _stash_caller(engine, ctx) + events_enabled = events != "off" and _supports_stream(engine) + mcp: FastMCP = FastMCP( + name=name, + instructions=instructions or _instructions(ctx, events_enabled=events_enabled), + ) + registry = OperationToolRegistry() + register_vocabulary(mcp, engine, is_async=True) + register_caller_context(mcp, ctx) + if include_operations: + register_operations( + mcp, + engine, + is_async=True, + registry=registry, + hidden=not expose_operations, + ) + if include_plan_tools: + register_plan_tools(mcp, engine, is_async=True, registry=registry) + register_events(mcp, engine, mode=events, source=event_source) + return mcp diff --git a/src/libtmux/experimental/mcp/plan_tools.py b/src/libtmux/experimental/mcp/plan_tools.py new file mode 100644 index 000000000..bc5bc7101 --- /dev/null +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -0,0 +1,144 @@ +"""Plan-tier tools: preview a plan, execute it (with bindings), introspect. + +These wrap the Core :class:`~..ops.plan.LazyPlan` for an agent: a pure dry-run, a +typed execution that returns JSON-serialisable per-op results plus a forward-ref +``bindings`` map, and a result-schema query so an agent can learn what ids a step +will yield *before* composing the next step. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.serialize import ( + bindings_to_dict, + operation_to_dict, + result_to_dict, +) + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.mcp.registry import OperationToolRegistry + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.planner import Planner + + +@dataclass(frozen=True) +class PlanPreview: + """A pure dry-run of a plan: per-op dicts + rendered argv (or ``None``).""" + + operations: list[dict[str, t.Any]] + argv: list[tuple[str, ...] | None] + + @property + def ok(self) -> bool: + """Whether every operation rendered (no unresolved forward refs).""" + return all(item is not None for item in self.argv) + + +def preview_plan(plan: LazyPlan, *, version: str | None = None) -> PlanPreview: + """Render a plan without executing it (forward-ref steps render as ``None``).""" + return PlanPreview( + operations=[operation_to_dict(op) for op in plan.operations], + argv=plan.preview(version=version), + ) + + +@dataclass(frozen=True) +class PlanOutcome: + """The result of executing a plan: per-op result dicts + a bindings map.""" + + ok: bool + results: list[dict[str, t.Any]] + bindings: dict[str, str] + + +def execute_plan( + plan: LazyPlan, + engine: TmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, +) -> PlanOutcome: + """Execute *plan* over *engine*; return JSON-friendly results + bindings.""" + result = plan.execute(engine, version=version, planner=planner) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) + + +async def aexecute_plan( + plan: LazyPlan, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, +) -> PlanOutcome: + """Async sibling of :func:`execute_plan` (same shape).""" + result = await plan.aexecute(engine, version=version, planner=planner) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) + + +@dataclass(frozen=True) +class ResultSchema: + """A result type's schema + the fields an agent can bind downstream.""" + + kind: str + result_type: str + schema: dict[str, t.Any] + binding_fields: list[str] + + +def result_schema(registry: OperationToolRegistry, kind: str) -> ResultSchema: + """Introspect what *kind* returns -- so an agent can plan forward refs. + + ``binding_fields`` are the result fields carrying ids an agent would reference + in a later step (``*_id`` / ``new_id``); they are read from the result + dataclass directly, so they do not depend on the JSON-schema backend. + """ + import dataclasses + + from libtmux.experimental.ops import registry as ops_registry + + descriptor = registry.descriptor(kind) + fields = [ + field.name for field in dataclasses.fields(ops_registry.get(kind).result_cls) + ] + binding_fields = [ + name for name in fields if name == "new_id" or name.endswith("_id") + ] + return ResultSchema( + kind=kind, + result_type=descriptor.result_type, + schema=dict(descriptor.result_schema), + binding_fields=binding_fields, + ) + + +def build_workspace( + spec: t.Mapping[str, t.Any] | str, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanOutcome: + """Build a declarative workspace (the Declarative tier) as one tool call. + + *spec* is a tmux-style mapping or YAML string (see + :func:`~..workspace.analyzer.analyze`). + """ + from libtmux.experimental.workspace import analyze + + result = analyze(spec).build(engine, version=version, preflight=preflight) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) diff --git a/src/libtmux/experimental/mcp/registry.py b/src/libtmux/experimental/mcp/registry.py new file mode 100644 index 000000000..6f153d35e --- /dev/null +++ b/src/libtmux/experimental/mcp/registry.py @@ -0,0 +1,166 @@ +"""Generate :class:`~.descriptor.ToolDescriptor` values from the op registry. + +One descriptor per registered operation ``kind``, derived by introspecting the +operation dataclass (fields + type hints + NumPy-docstring params) and its +``OpSpec`` metadata (scope/safety/effects/version gates). Zero MCP-framework +coupling: the result is plain data + a builder. +""" + +from __future__ import annotations + +import dataclasses +import typing as t + +from libtmux.experimental.mcp.descriptor import ParamDescriptor, ToolDescriptor +from libtmux.experimental.mcp.schema import schema_for_type +from libtmux.experimental.ops import registry as ops_registry + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.registry import OpSpec + +_ANNOTATIONS: dict[str, dict[str, bool]] = { + "readonly": {"readOnlyHint": True}, + "mutating": {"readOnlyHint": False}, + "destructive": {"readOnlyHint": False, "destructiveHint": True}, +} +_SKIP_FIELDS = frozenset({"target", "src_target"}) +_SCALAR_NAME = {"bool": "bool", "int": "int", "float": "float", "str": "str"} +_LIST_BASES = frozenset({"list", "tuple", "Sequence", "frozenset", "set"}) +_DICT_BASES = frozenset({"dict", "Mapping", "MutableMapping"}) + + +def _origin_of(annotation: t.Any) -> tuple[str, str | None]: + """Map a field annotation to a ``(origin, item_origin)`` schema pair. + + Parses the annotation *string* (operations use ``from __future__ import + annotations``, and their hints reference ``TYPE_CHECKING``-only names like + ``Mapping`` that ``get_type_hints`` cannot resolve at runtime), so it never + needs to import the annotated types. + """ + text = ( + annotation + if isinstance(annotation, str) + else getattr(annotation, "__name__", str(annotation)) + ) + text = text.replace(" ", "") + if "|" in text: + members = [member for member in text.split("|") if member and member != "None"] + text = members[0] if members else "str" + base = text.split("[", 1)[0] + if base in _LIST_BASES: + inner = text[len(base) + 1 : -1] if "[" in text else "" + item = inner.split("[", 1)[0].split(",", 1)[0] if inner else "str" + return "list", _SCALAR_NAME.get(item, "str") + if base in _DICT_BASES: + return "dict", None + return _SCALAR_NAME.get(base, "str"), None + + +def _docstring_params(doc: str | None) -> dict[str, str]: + """Parse ``name : type`` entries from a NumPy docstring Parameters block.""" + if not doc: + return {} + out: dict[str, str] = {} + in_params = False + pending: str | None = None + for raw in doc.splitlines(): + line = raw.rstrip() + if line.strip() in {"Parameters", "Attributes"}: + in_params = True + continue + if not in_params: + continue + if line.strip().startswith(("Returns", "Examples", "Notes", "Raises")): + break + if " : " in line and not line.startswith(" "): + pending = line.split(" : ", 1)[0].strip() + elif pending and line.startswith(" ") and line.strip(): + out.setdefault(pending, line.strip()) + pending = None + return out + + +def _summary(doc: str | None) -> str: + """Return the first non-empty docstring line.""" + for line in (doc or "").splitlines(): + if line.strip(): + return line.strip() + return "" + + +class OperationToolRegistry: + """Build (and cache) a :class:`~.descriptor.ToolDescriptor` per operation. + + Examples + -------- + >>> reg = OperationToolRegistry() + >>> d = reg.descriptor("split_window") + >>> d.name, d.scope, d.safety + ('split_window', 'window', 'mutating') + >>> d.params["horizontal"].origin + 'bool' + >>> d.build(target="@1", horizontal=True).render() + ('split-window', '-t', '@1', '-h', '-P', '-F', '#{pane_id}') + >>> len(reg.descriptors()) == len(list(reg.kinds())) + True + """ + + def __init__(self) -> None: + self._cache: dict[str, ToolDescriptor] = {} + + def kinds(self) -> tuple[str, ...]: + """Return every registered operation kind, sorted.""" + return ops_registry.kinds() + + def descriptor(self, kind: str) -> ToolDescriptor: + """Return (building + caching) the descriptor for *kind*.""" + cached = self._cache.get(kind) + if cached is not None: + return cached + built = self._build(ops_registry.get(kind)) + self._cache[kind] = built + return built + + def descriptors(self) -> list[ToolDescriptor]: + """Return a descriptor for every registered operation, sorted by name.""" + return [self.descriptor(spec.kind) for spec in ops_registry.select()] + + def _build(self, spec: OpSpec) -> ToolDescriptor: + """Project one ``OpSpec`` into a tool descriptor.""" + return ToolDescriptor( + name=spec.kind, + title=spec.kind.replace("_", " ").title(), + description=_summary(spec.operation_cls.__doc__), + scope=spec.scope, + safety=spec.safety, + params=self._params(spec), + result_type=spec.result_cls.__name__, + result_schema=schema_for_type(spec.result_cls), + annotations=_ANNOTATIONS.get(spec.safety, {}), + tags=frozenset({spec.safety}), + version_gates=dict(spec.flag_version_map), + effects=dataclasses.asdict(spec.effects), + operation_cls=spec.operation_cls, + ) + + def _params(self, spec: OpSpec) -> dict[str, ParamDescriptor]: + """Extract typed parameter descriptors from the operation's fields.""" + operation_cls = spec.operation_cls + docs = _docstring_params(operation_cls.__doc__) + params: dict[str, ParamDescriptor] = {} + for field in dataclasses.fields(operation_cls): + if field.name in _SKIP_FIELDS: + continue + origin, item = _origin_of(field.type) + params[field.name] = ParamDescriptor( + name=field.name, + origin=origin, + item_origin=item, + is_required=( + field.default is dataclasses.MISSING + and field.default_factory is dataclasses.MISSING + ), + description=docs.get(field.name), + version_gate=spec.flag_version_map.get(field.name), + ) + return params diff --git a/src/libtmux/experimental/mcp/schema.py b/src/libtmux/experimental/mcp/schema.py new file mode 100644 index 000000000..cedbfe811 --- /dev/null +++ b/src/libtmux/experimental/mcp/schema.py @@ -0,0 +1,75 @@ +"""Best-effort JSON-schema generation for result types. + +The single place pydantic is *optional*: if installed, its ``TypeAdapter`` gives a +precise schema; otherwise a small stdlib introspection produces a serviceable one. +Either way the projection core has no hard pydantic dependency. +""" + +from __future__ import annotations + +import collections.abc +import dataclasses +import typing as t + +_SCALARS: dict[type, str] = { + int: "integer", + float: "number", + str: "string", + bool: "boolean", +} + + +def schema_for_type(tp: type) -> dict[str, t.Any]: + """Return a JSON-schema dict for *tp* (pydantic if available, else stdlib). + + Examples + -------- + >>> schema_for_type(int) + {'type': 'integer'} + >>> schema_for_type(str) + {'type': 'string'} + """ + import importlib + + try: + type_adapter = importlib.import_module("pydantic").TypeAdapter + except ImportError: + return _introspect(tp) + try: + return dict(type_adapter(tp).json_schema()) + except Exception: # pydantic rejects some dataclasses -- fall back to stdlib + return _introspect(tp) + + +def _introspect(tp: t.Any) -> dict[str, t.Any]: + """Render a coarse JSON schema by walking dataclass fields / generics.""" + if dataclasses.is_dataclass(tp) and isinstance(tp, type): + try: + hints = t.get_type_hints(tp) + except Exception: + hints = {} + props: dict[str, t.Any] = {} + required: list[str] = [] + for field in dataclasses.fields(tp): + if field.name == "operation": # back-reference to the source op, not output + continue + props[field.name] = _introspect(hints.get(field.name, str)) + if ( + field.default is dataclasses.MISSING + and field.default_factory is dataclasses.MISSING + ): + required.append(field.name) + schema: dict[str, t.Any] = {"type": "object", "properties": props} + if required: + schema["required"] = required + return schema + if tp in _SCALARS: + return {"type": _SCALARS[tp]} + origin = t.get_origin(tp) + if origin in (list, tuple): + args = t.get_args(tp) + item = _introspect(args[0]) if args else {"type": "string"} + return {"type": "array", "items": item} + if origin in (dict, collections.abc.Mapping): + return {"type": "object"} + return {"type": "string"} diff --git a/src/libtmux/experimental/mcp/target_resolver.py b/src/libtmux/experimental/mcp/target_resolver.py new file mode 100644 index 000000000..000c9b59e --- /dev/null +++ b/src/libtmux/experimental/mcp/target_resolver.py @@ -0,0 +1,87 @@ +"""Resolve agent-supplied targets to typed :data:`~..ops._types.Target` values. + +The string/dict boundary between an MCP client (which speaks JSON) and the typed +operation spine. Fail-closed: an unrecognised target raises rather than guessing. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.ops._types import ( + ClientName, + IndexRef, + NameRef, + PaneId, + SessionId, + SlotRef, + Special, + WindowId, +) +from libtmux.experimental.ops.serialize import target_from_dict + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Target + +_TARGET_CLASSES = ( + PaneId, + WindowId, + SessionId, + ClientName, + NameRef, + IndexRef, + Special, + SlotRef, +) + + +def resolve_target(value: str | Mapping[str, t.Any] | Target | None) -> Target | None: + """Coerce a target spec into a typed :data:`~..ops._types.Target`. + + Accepts an already-typed target (passthrough), the tagged dict form from + :func:`~..ops.serialize.target_to_dict`, ``None``, or a string using tmux + sigils: ``%``→pane, ``@``→window, ``$``→session, ``/``→client, ``{...}``→ + special, ``=name``→exact name, otherwise a prefix-matched name. + + Examples + -------- + >>> resolve_target("%1") + PaneId(value='%1') + >>> resolve_target("@2") + WindowId(value='@2') + >>> resolve_target("work") + NameRef(name='work', exact=False) + >>> resolve_target({"type": "PaneId", "value": "%3"}) + PaneId(value='%3') + >>> resolve_target(None) is None + True + """ + if value is None: + return None + if isinstance(value, _TARGET_CLASSES): + return value + if isinstance(value, str): + return _from_string(value) + return target_from_dict(value) + + +def _from_string(value: str) -> Target: + """Parse a target string by its tmux sigil (fail-closed on empty).""" + if not value: + msg = "empty target string" + raise ValueError(msg) + if value.startswith("%"): + return PaneId(value) + if value.startswith("@"): + return WindowId(value) + if value.startswith("$"): + return SessionId(value) + if value.startswith("/"): + return ClientName(value) + if value.startswith("{") and value.endswith("}"): + return Special(value) + if value.startswith("="): + return NameRef(value[1:], exact=True) + return NameRef(value) diff --git a/src/libtmux/experimental/mcp/vocabulary/__init__.py b/src/libtmux/experimental/mcp/vocabulary/__init__.py new file mode 100644 index 000000000..f39e93832 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/__init__.py @@ -0,0 +1,226 @@ +"""Curated core vocabulary -- the intuitive, named tmux tools. + +The Layer-1 surface: a small set of hand-written functions that mirror libtmux's +ORM (``server.new_session`` / ``window.split_window`` / ``pane.send_keys``) but +run over any engine and return small, typed result objects. Each tool is written +once as an ``async def`` (the ``a``-prefixed names, the canonical async-first +surface) and exposed as a derived synchronous twin under the plain name (see +:mod:`._bridge`). Tools are grouped by scope in submodules +(:mod:`.session` / :mod:`.window` / :mod:`.pane` / :mod:`.buffer` / +:mod:`.option` / :mod:`.server`). + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> engine = ConcreteEngine() +>>> session = create_session(engine, name="dev") +>>> session.session_id +'$1' +>>> pane = split_pane(engine, session.first_pane_id or "%1", horizontal=True) +>>> pane.pane_id +'%2' +>>> send_input(engine, pane.pane_id, "pytest -q", enter=True) is None +True +""" + +from __future__ import annotations + +from libtmux.experimental.mcp.vocabulary._caller import CallerContext +from libtmux.experimental.mcp.vocabulary._results import ( + BufferText, + Listing, + MessageText, + OptionMap, + PaneCapture, + PaneMatch, + PaneRef, + PaneResult, + PaneSearch, + RawResult, + SessionResult, + WindowResult, +) +from libtmux.experimental.mcp.vocabulary.buffer import ( + apaste_buffer, + aset_buffer, + ashow_buffer, + paste_buffer, + set_buffer, + show_buffer, +) +from libtmux.experimental.mcp.vocabulary.option import ( + aset_option, + ashow_options, + set_option, + show_options, +) +from libtmux.experimental.mcp.vocabulary.pane import ( + abreak_pane, + acapture_active_pane, + acapture_pane, + acapture_relative_pane, + afind_pane_by_position, + agrep_pane, + agrep_relative_pane, + ajoin_pane, + akill_pane, + alist_panes, + aresize_pane, + aresolve_relative_pane, + arespawn_pane, + asearch_panes, + aselect_pane, + asend_input, + asplit_pane, + aswap_pane, + break_pane, + capture_active_pane, + capture_pane, + capture_relative_pane, + find_pane_by_position, + grep_pane, + grep_relative_pane, + join_pane, + kill_pane, + list_panes, + resize_pane, + resolve_relative_pane, + respawn_pane, + search_panes, + select_pane, + send_input, + split_pane, + swap_pane, +) +from libtmux.experimental.mcp.vocabulary.server import ( + adisplay_message, + alist_clients, + arun_tmux, + display_message, + list_clients, + run_tmux, +) +from libtmux.experimental.mcp.vocabulary.session import ( + acreate_session, + ahas_session, + akill_session, + alist_sessions, + arename_session, + create_session, + has_session, + kill_session, + list_sessions, + rename_session, +) +from libtmux.experimental.mcp.vocabulary.window import ( + acreate_window, + akill_window, + alist_windows, + amove_window, + arename_window, + aselect_layout, + aselect_window, + aswap_window, + create_window, + kill_window, + list_windows, + move_window, + rename_window, + select_layout, + select_window, + swap_window, +) + +__all__ = ( + "BufferText", + "CallerContext", + "Listing", + "MessageText", + "OptionMap", + "PaneCapture", + "PaneMatch", + "PaneRef", + "PaneResult", + "PaneSearch", + "RawResult", + "SessionResult", + "WindowResult", + "abreak_pane", + "acapture_active_pane", + "acapture_pane", + "acapture_relative_pane", + "acreate_session", + "acreate_window", + "adisplay_message", + "afind_pane_by_position", + "agrep_pane", + "agrep_relative_pane", + "ahas_session", + "ajoin_pane", + "akill_pane", + "akill_session", + "akill_window", + "alist_clients", + "alist_panes", + "alist_sessions", + "alist_windows", + "amove_window", + "apaste_buffer", + "arename_session", + "arename_window", + "aresize_pane", + "aresolve_relative_pane", + "arespawn_pane", + "arun_tmux", + "asearch_panes", + "aselect_layout", + "aselect_pane", + "aselect_window", + "asend_input", + "aset_buffer", + "aset_option", + "ashow_buffer", + "ashow_options", + "asplit_pane", + "aswap_pane", + "aswap_window", + "break_pane", + "capture_active_pane", + "capture_pane", + "capture_relative_pane", + "create_session", + "create_window", + "display_message", + "find_pane_by_position", + "grep_pane", + "grep_relative_pane", + "has_session", + "join_pane", + "kill_pane", + "kill_session", + "kill_window", + "list_clients", + "list_panes", + "list_sessions", + "list_windows", + "move_window", + "paste_buffer", + "rename_session", + "rename_window", + "resize_pane", + "resolve_relative_pane", + "respawn_pane", + "run_tmux", + "search_panes", + "select_layout", + "select_pane", + "select_window", + "send_input", + "set_buffer", + "set_option", + "show_buffer", + "show_options", + "split_pane", + "swap_pane", + "swap_window", +) diff --git a/src/libtmux/experimental/mcp/vocabulary/_bridge.py b/src/libtmux/experimental/mcp/vocabulary/_bridge.py new file mode 100644 index 000000000..db78380d7 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_bridge.py @@ -0,0 +1,113 @@ +"""Sync bridge: drive one async tool body over a synchronous engine. + +The curated vocabulary is written once as ``async def`` over an +:class:`~libtmux.experimental.engines.base.AsyncTmuxEngine` (the canonical, +"async-first" surface). A synchronous twin is *derived* -- not hand-written -- +by :func:`synced`, which wraps a plain :class:`~..engines.base.TmuxEngine` in the +async protocol (:class:`SyncToAsyncEngine`) and drives the coroutine to +completion with a sans-I/O trampoline (:func:`drive_sync`). + +This is sound because every curated tool's only ``await`` is a single +``arun(op, engine)`` -- and the wrapped sync engine's ``run`` returns inline, +never suspending on a real :class:`asyncio.Future`. The trampoline therefore +runs the whole coroutine in one ``send(None)``, needing no event loop, and works +even when called from inside a running loop. A tool that *does* suspend (the +event stream) has no sync twin and raises here, by design. +""" + +from __future__ import annotations + +import functools +import inspect +import typing as t + +# Imported at runtime (not under TYPE_CHECKING) so the derived sync twin's +# ``engine`` annotation resolves when the fastmcp adapter calls get_type_hints(). +from libtmux.experimental.engines.base import TmuxEngine + +if t.TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + +R = t.TypeVar("R") + + +class SyncToAsyncEngine: + """Adapt a synchronous :class:`TmuxEngine` to the async engine protocol. + + Each ``await`` resolves inline (the underlying call is synchronous), so a + coroutine awaiting only this adapter never yields to an event loop. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> bridge = SyncToAsyncEngine(ConcreteEngine()) + >>> hasattr(bridge, "run") and hasattr(bridge, "run_batch") + True + """ + + def __init__(self, engine: TmuxEngine) -> None: + self._engine = engine + + def __getattr__(self, name: str) -> t.Any: + """Delegate unknown attributes to the wrapped engine. + + Keeps the wrapper transparent for the attributes the vocabulary reads off + an engine -- ``server_args`` (socket scoping) and the stashed + ``_caller_context`` -- so the sync surface sees the same identity the + async surface does. + """ + return getattr(self._engine, name) + + async def run(self, request: CommandRequest) -> CommandResult: + """Run one command on the wrapped sync engine (resolves inline).""" + return self._engine.run(request) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Run a batch on the wrapped sync engine (resolves inline).""" + return self._engine.run_batch(requests) + + +def drive_sync(coro: Awaitable[R]) -> R: + """Run *coro* to completion synchronously, without an event loop. + + Works only for coroutines whose awaits never suspend on a real future + (the curated tools, driven over a :class:`SyncToAsyncEngine`). + + Raises + ------ + RuntimeError + If the coroutine suspends on real I/O -- use the async surface instead. + """ + runner = t.cast("t.Coroutine[t.Any, t.Any, R]", coro) + try: + runner.send(None) + except StopIteration as stop: + return t.cast("R", stop.value) + runner.close() + msg = "sync bridge: tool awaited real I/O; call it on the async surface" + raise RuntimeError(msg) + + +def synced(afn: Callable[..., Awaitable[R]]) -> Callable[..., R]: + """Derive a synchronous twin of an async tool ``afn(engine, ...)``. + + The twin takes a sync :class:`TmuxEngine`, wraps it as async, and drives the + same coroutine to completion -- so each tool's logic is written exactly once. + """ + hints = t.get_type_hints(afn) + signature = inspect.signature(afn) + + @functools.wraps(afn) + def wrapper(engine: TmuxEngine, *args: t.Any, **kwargs: t.Any) -> R: + return drive_sync(afn(SyncToAsyncEngine(engine), *args, **kwargs)) + + twin_hints = dict(hints) + twin_hints["engine"] = TmuxEngine + wrapper.__annotations__ = twin_hints + wrapper.__signature__ = signature # type: ignore[attr-defined] + return wrapper diff --git a/src/libtmux/experimental/mcp/vocabulary/_caller.py b/src/libtmux/experimental/mcp/vocabulary/_caller.py new file mode 100644 index 000000000..ae080749f --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_caller.py @@ -0,0 +1,393 @@ +"""Caller context: who launched this MCP server, discovered from the environment. + +A tmux ``-C`` control client resolves a no-target or relative target against its +*own* cursor pane, never the pane that launched the controlling process -- so the +caller pane is knowable only from the server process's environment, not by asking +tmux. A process spawned inside a tmux pane inherits ``TMUX_PANE`` (its ``%N``) and +``TMUX`` (``socket-path,server-pid,session-id``). + +But real launchers strip that env: an agent harness may hold ``TMUX``/ +``TMUX_PANE`` while the ``uv run`` child that became this server does not. So +:meth:`CallerContext.discover` layers the server's own env, an explicit override, +and a bounded same-uid ``/proc`` parent walk (:mod:`._proc`) to recover the pane. + +Everything here is pure (no tmux call, no fastmcp). A pane id is unique only +within one tmux server, so identity is socket-scoped: :func:`is_strict_caller` +(realpath-only, for the ``is_caller`` annotation) and the fail-safe +:func:`is_conservative_caller` (true-when-uncertain, for destructive guards). +""" + +from __future__ import annotations + +import dataclasses +import os +import os.path +import pathlib +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.mcp.vocabulary._proc import ( + read_proc_environ, + read_proc_ppid, + read_proc_uid, +) + +if t.TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + + +@dataclass(frozen=True) +class CallerContext: + """The tmux pane/server that launched this MCP, parsed from the environment. + + Examples + -------- + >>> env = {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"} + >>> c = CallerContext.from_env(env) + >>> (c.pane_id, c.socket_path, c.session_id, c.in_tmux, c.source) + ('%3', '/tmp/tmux-1000/default', '2', True, 'process-env') + >>> CallerContext.from_env({}).in_tmux + False + >>> CallerContext.from_env({"TMUX": "garbage"}).socket_path is None + True + >>> CallerContext.from_env({"TMUX": "/tmp/a,b/sock,1,2"}).socket_path + '/tmp/a,b/sock' + """ + + pane_id: str | None = None + socket_path: str | None = None + server_pid: str | None = None + session_id: str | None = None + in_tmux: bool = False + source: str = "none" + + @classmethod + def from_env(cls, environ: Mapping[str, str] | None = None) -> CallerContext: + """Parse the caller context from *environ* (defaults to ``os.environ``). + + Degrades gracefully: a missing ``TMUX``/``TMUX_PANE`` yields a context + with ``in_tmux=False``; a malformed ``TMUX`` (not three comma fields) + leaves the socket/pid/session ``None`` but still records the pane. The + ``pid`` and ``session`` are the final two comma fields, so the value is + split from the right -- a socket path may itself contain a comma. + """ + env = os.environ if environ is None else environ + pane = env.get("TMUX_PANE") or None + raw = env.get("TMUX") or None + socket_path = server_pid = session_id = None + if raw is not None: + parts = raw.rsplit(",", 2) + if len(parts) == 3: + socket_path, server_pid, session_id = parts + return cls( + pane_id=pane, + socket_path=socket_path, + server_pid=server_pid, + session_id=session_id, + in_tmux=pane is not None, + source="process-env" if pane is not None else "none", + ) + + @classmethod + def discover( + cls, + *, + environ: Mapping[str, str] | None = None, + read_env: Callable[[int], Mapping[str, str] | None] = read_proc_environ, + read_ppid: Callable[[int], int | None] = read_proc_ppid, + read_uid: Callable[[int], int | None] = read_proc_uid, + self_pid: int | None = None, + self_uid: int | None = None, + is_linux: bool | None = None, + max_depth: int = 32, + ) -> CallerContext: + """Discover the caller pane: own env, then an override, then a /proc walk. + + Recovers the launching pane even when the MCP's own environment was + stripped. Precedence (first source that is inside tmux wins, recorded in + :attr:`source`): + + 1. ``process-env`` -- the server's own ``TMUX``/``TMUX_PANE``. + 2. ``explicit-override`` -- ``LIBTMUX_MCP_CALLER_PANE`` (+ optional + ``LIBTMUX_MCP_CALLER_TMUX``); the trusted escape hatch. + 3. ``parent-walk`` -- a bounded, same-uid Linux ``/proc`` ancestor climb + (disabled by ``LIBTMUX_MCP_DISCOVER=0`` or off Linux). + + Never raises: a reader failure, uid mismatch, or missing ``/proc`` + degrades to the next source and ultimately ``source="none"``. The reader + callables are injectable so the walk is unit-testable without ``/proc``. + + Examples + -------- + >>> env = {10: {}, 20: {}, 30: {"TMUX_PANE": "%3", "TMUX": "/tmp/s,1,2"}} + >>> ppid = {10: 20, 20: 30, 30: 1} + >>> c = CallerContext.discover( + ... environ={}, + ... read_env=env.get, + ... read_ppid=ppid.get, + ... read_uid=lambda _pid: 1000, + ... self_pid=10, + ... self_uid=1000, + ... is_linux=True, + ... ) + >>> (c.pane_id, c.socket_path, c.source) + ('%3', '/tmp/s', 'parent-walk') + """ + env = os.environ if environ is None else environ + own = cls.from_env(env) + if own.in_tmux: + return own + override_pane = env.get("LIBTMUX_MCP_CALLER_PANE") + if override_pane: + override: dict[str, str] = {"TMUX_PANE": override_pane} + override_tmux = env.get("LIBTMUX_MCP_CALLER_TMUX") + if override_tmux: + override["TMUX"] = override_tmux + return dataclasses.replace( + cls.from_env(override), + source="explicit-override", + ) + if env.get("LIBTMUX_MCP_DISCOVER") == "0": + return cls(source="none") + linux = pathlib.Path("/proc").is_dir() if is_linux is None else is_linux + if linux: + walked = cls._parent_walk( + read_env, + read_ppid, + read_uid, + self_pid, + self_uid, + max_depth, + ) + if walked is not None: + return walked + return cls(source="none") + + @classmethod + def _parent_walk( + cls, + read_env: Callable[[int], Mapping[str, str] | None], + read_ppid: Callable[[int], int | None], + read_uid: Callable[[int], int | None], + self_pid: int | None, + self_uid: int | None, + max_depth: int, + ) -> CallerContext | None: + """Climb the parent chain for the first same-uid ancestor inside tmux.""" + pid = os.getpid() if self_pid is None else self_pid + uid = os.getuid() if self_uid is None else self_uid + seen: set[int] = set() + for _ in range(max_depth): + ppid = read_ppid(pid) + if ppid is None or ppid in (0, 1) or ppid in seen: + return None + if read_uid(ppid) != uid: + return None # never read a foreign or setuid parent's env + seen.add(ppid) + env = read_env(ppid) + if env is None: + return None + ctx = cls.from_env(env) + if ctx.in_tmux: + return dataclasses.replace(ctx, source="parent-walk") + pid = ppid + return None + + +def _scan_flag(args: Sequence[str], flag: str) -> str | None: + """Read a tmux connection flag's value (joined ``-Sx`` or separated ``-S x``).""" + for index, arg in enumerate(args): + if arg == flag and index + 1 < len(args): + return args[index + 1] or None + if arg.startswith(flag) and len(arg) > len(flag): + return arg[len(flag) :] + return None + + +def engine_socket(engine: t.Any) -> str | None: + """Return the socket selector an engine targets (``-S`` path / ``-L`` name). + + Prefers an explicit ``-S`` path (the most precise selector) over a ``-L`` + name. ``None`` means the engine uses the ambient ``$TMUX`` server -- the same + server as a caller running inside tmux. + + Examples + -------- + >>> import types + >>> engine_socket(types.SimpleNamespace(server_args=("-Lwork",))) + 'work' + >>> engine_socket(types.SimpleNamespace(server_args=("-S", "/tmp/x"))) + '/tmp/x' + >>> engine_socket(types.SimpleNamespace(server_args=())) is None + True + """ + args = tuple(getattr(engine, "server_args", ()) or ()) + path = _scan_flag(args, "-S") + if path is not None: + return path + return _scan_flag(args, "-L") + + +def caller_server_args(caller: CallerContext, *, explicit: bool) -> tuple[str, ...]: + """Return ``("-S", socket)`` to bind the caller's server, or ``()``. + + Binds only when the caller's socket was discovered and no explicit socket + override was supplied -- so a stripped-env MCP still drives the user's own + tmux server instead of spawning a fresh default one. + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/tmp/s,1,2"}) + >>> caller_server_args(caller, explicit=False) + ('-S', '/tmp/s') + >>> caller_server_args(caller, explicit=True) + () + >>> caller_server_args(CallerContext.from_env({}), explicit=False) + () + """ + if explicit or not caller.in_tmux or caller.socket_path is None: + return () + return ("-S", caller.socket_path) + + +def socket_matches(socket: str | None, caller: CallerContext) -> bool: + """Whether an engine *socket* selector denotes the caller's tmux server. + + A default engine (``socket is None``) talks to the ambient ``$TMUX`` server, + which is the caller's server when the caller is inside tmux *and* its socket + is known. A ``-S`` path is realpath-compared; a ``-L`` name is resolved to its + per-user socket path (honouring ``$TMUX_TMPDIR``) and realpath-compared, so a + bare name cannot collide with an unrelated socket's basename. + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/tmp/s,1,2"}) + >>> socket_matches(None, caller) + True + >>> socket_matches("/tmp/s", caller) + True + >>> socket_matches("/tmp/other", caller) + False + """ + if socket is None: + # An unbound engine talks to the ambient $TMUX server, which is the + # caller's only when the MCP itself inherited it (process-env). A + # parent-walked caller's socket is NOT the ambient default. + return ( + caller.in_tmux + and caller.socket_path is not None + and caller.source == "process-env" + ) + if caller.socket_path is None: + return False + if "/" in socket: + try: + return os.path.realpath(socket) == os.path.realpath(caller.socket_path) + except OSError: + return socket == caller.socket_path + tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" + expected = f"{tmpdir}/tmux-{os.getuid()}/{socket}" + try: + return os.path.realpath(expected) == os.path.realpath(caller.socket_path) + except OSError: + return expected == caller.socket_path + + +def socket_could_match(socket: str | None, caller: CallerContext) -> bool: + """Conservative socket comparator: True unless the caller is provably elsewhere. + + The fail-safe counterpart to :func:`socket_matches`, for destructive guards: + it blocks (returns ``True``) whenever it cannot *disprove* that *socket* is + the caller's server -- an unknown caller socket, an ambient default engine, + or a last-chance basename match all count, so a self-kill is refused under + uncertainty (e.g. a ``$TMUX_TMPDIR`` divergence). + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/tmp/s,1,2"}) + >>> socket_could_match(None, caller) + True + >>> socket_could_match("/tmp/s", caller) + True + >>> socket_could_match("/tmp/other", caller) + False + >>> socket_could_match(None, CallerContext.from_env({})) + False + """ + if not caller.in_tmux: + return False + if caller.socket_path is None: + return True + if socket is None: + # Ambient default engine: the caller's server only when the MCP inherited + # $TMUX (process-env), not when its pane was parent-walked or overridden. + return caller.source == "process-env" + if "/" in socket: + try: + return os.path.realpath(socket) == os.path.realpath(caller.socket_path) + except OSError: + return socket == caller.socket_path + tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" + expected = f"{tmpdir}/tmux-{os.getuid()}/{socket}" + try: + if os.path.realpath(expected) == os.path.realpath(caller.socket_path): + return True + except OSError: + pass + return caller.socket_path.rsplit("/", 1)[-1] == socket + + +def is_strict_caller( + pane_id: str | None, + socket: str | None, + caller: CallerContext, +) -> bool: + """Whether *pane_id* on an engine bound to *socket* is the caller's own pane. + + Strict: requires pane-id equality *and* a confirmed socket match, since a + pane id is unique only within one tmux server. Bare pane-id equality is + rejected to avoid a cross-server false positive. Used for the ``is_caller`` + annotation and the caller-default origin -- both must demand a positive match. + + Examples + -------- + >>> caller = CallerContext.from_env( + ... {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"} + ... ) + >>> is_strict_caller("%3", None, caller) + True + >>> is_strict_caller("%9", None, caller) + False + >>> is_strict_caller("%3", "/tmp/tmux-1000/other", caller) + False + """ + if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: + return False + return socket_matches(socket, caller) + + +def is_conservative_caller( + pane_id: str | None, + socket: str | None, + caller: CallerContext, +) -> bool: + """Whether *pane_id* could be the caller's pane (conservative, for guards). + + Scoped to a matching pane id but biased to block under socket uncertainty -- + the comparator the self-kill guards use, so better discovery never makes the + destructive surface fail open. A different pane, or the same ``%N`` on a + provably different socket, is not the caller. + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%3", "TMUX": "/tmp/s,1,2"}) + >>> is_conservative_caller("%3", None, caller) + True + >>> is_conservative_caller("%9", None, caller) + False + >>> is_conservative_caller("%3", "/tmp/other", caller) + False + """ + if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: + return False + return socket_could_match(socket, caller) diff --git a/src/libtmux/experimental/mcp/vocabulary/_geometry.py b/src/libtmux/experimental/mcp/vocabulary/_geometry.py new file mode 100644 index 000000000..9eaeb2944 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_geometry.py @@ -0,0 +1,162 @@ +"""Pure pane-geometry helpers for directional and corner resolution. + +tmux's own ``{up-of}`` / ``{down-of}`` target tokens always pivot on the +*active* pane and vary across tmux versions, so resolving "the pane to the right +of %5" robustly means reading the layout geometry and computing the neighbour +ourselves -- the same lesson libtmux-mcp's ``select_pane``/``find_pane_by_position`` +encode. These helpers operate on the ``pane_left/top/right/bottom`` and +``pane_at_*`` fields the ``list-panes`` template already carries, so they need no +extra tmux round-trip beyond the one list. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + +#: Directions a pane neighbour can be resolved in. +Direction = t.Literal["up", "down", "left", "right"] +#: The four window corners. +Corner = t.Literal["top-left", "top-right", "bottom-left", "bottom-right"] + + +def _as_int(value: str | None) -> int: + """Parse a tmux integer format value, defaulting to ``0``.""" + if value is None or value == "": + return 0 + try: + return int(value) + except ValueError: + return 0 + + +def _as_bool(value: str | None) -> bool: + """Parse a tmux flag value (``"1"``/``"0"``/``""``).""" + return value == "1" + + +@dataclass(frozen=True) +class PaneBox: + """One pane's geometry, parsed from a ``list-panes`` format row.""" + + pane_id: str + left: int + top: int + right: int + bottom: int + at_left: bool + at_right: bool + at_top: bool + at_bottom: bool + active: bool + + @classmethod + def from_row(cls, row: Mapping[str, str]) -> PaneBox: + """Build a box from a tmux format mapping.""" + return cls( + pane_id=row.get("pane_id", ""), + left=_as_int(row.get("pane_left")), + top=_as_int(row.get("pane_top")), + right=_as_int(row.get("pane_right")), + bottom=_as_int(row.get("pane_bottom")), + at_left=_as_bool(row.get("pane_at_left")), + at_right=_as_bool(row.get("pane_at_right")), + at_top=_as_bool(row.get("pane_at_top")), + at_bottom=_as_bool(row.get("pane_at_bottom")), + active=_as_bool(row.get("pane_active")), + ) + + +def parse_boxes(rows: Sequence[Mapping[str, str]]) -> list[PaneBox]: + """Parse ``list-panes`` rows into geometry boxes.""" + return [PaneBox.from_row(row) for row in rows] + + +def _overlap(a0: int, a1: int, b0: int, b1: int) -> int: + """Inclusive 1-D overlap length of ``[a0, a1]`` and ``[b0, b1]``.""" + return max(0, min(a1, b1) - max(a0, b0) + 1) + + +def neighbor( + boxes: Sequence[PaneBox], + origin_id: str, + direction: Direction, +) -> str | None: + """Return the id of the pane adjacent to *origin_id* in *direction*. + + Picks the nearest pane on the requested side that shares a perpendicular + overlap with the origin; ``None`` when the origin is unknown or has no + neighbour that way. + + Examples + -------- + Two side-by-side panes -- the right neighbour of the left pane is the right + one, and the left pane has no neighbour above it: + + >>> rows = [ + ... {"pane_id": "%1", "pane_left": "0", "pane_top": "0", + ... "pane_right": "39", "pane_bottom": "23"}, + ... {"pane_id": "%2", "pane_left": "41", "pane_top": "0", + ... "pane_right": "80", "pane_bottom": "23"}, + ... ] + >>> boxes = parse_boxes(rows) + >>> neighbor(boxes, "%1", "right") + '%2' + >>> neighbor(boxes, "%1", "up") is None + True + """ + origin = next((b for b in boxes if b.pane_id == origin_id), None) + if origin is None: + return None + ranked: list[tuple[int, int, str]] = [] + for box in boxes: + if box.pane_id == origin_id: + continue + vspan = _overlap(box.top, box.bottom, origin.top, origin.bottom) + hspan = _overlap(box.left, box.right, origin.left, origin.right) + if direction == "right" and box.left > origin.right and vspan: + ranked.append((box.left, box.top, box.pane_id)) + elif direction == "left" and box.right < origin.left and vspan: + ranked.append((-box.right, box.top, box.pane_id)) + elif direction == "down" and box.top > origin.bottom and hspan: + ranked.append((box.top, box.left, box.pane_id)) + elif direction == "up" and box.bottom < origin.top and hspan: + ranked.append((-box.bottom, box.left, box.pane_id)) + if not ranked: + return None + ranked.sort() + return ranked[0][2] + + +def corner_pane(boxes: Sequence[PaneBox], corner: Corner) -> str | None: + """Return the id of the pane occupying *corner* of the window. + + Composes the two ``pane_at_*`` edge predicates; ties (e.g. a single pane + touching every edge) break toward the visually innermost pane. + + Examples + -------- + >>> rows = [ + ... {"pane_id": "%1", "pane_left": "0", "pane_top": "0", + ... "pane_at_left": "1", "pane_at_top": "1", "pane_at_right": "0", + ... "pane_at_bottom": "1"}, + ... {"pane_id": "%2", "pane_left": "41", "pane_top": "0", + ... "pane_at_left": "0", "pane_at_top": "1", "pane_at_right": "1", + ... "pane_at_bottom": "1"}, + ... ] + >>> corner_pane(parse_boxes(rows), "top-right") + '%2' + """ + vertical, horizontal = corner.split("-") + matches = [ + box + for box in boxes + if getattr(box, f"at_{vertical}") and getattr(box, f"at_{horizontal}") + ] + if not matches: + return None + matches.sort(key=lambda b: b.left + b.top, reverse=True) + return matches[0].pane_id diff --git a/src/libtmux/experimental/mcp/vocabulary/_proc.py b/src/libtmux/experimental/mcp/vocabulary/_proc.py new file mode 100644 index 000000000..a8fc35c5c --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_proc.py @@ -0,0 +1,87 @@ +"""Linux ``/proc`` readers for the caller-discovery parent walk (pure, fail-closed). + +Recovering the launching pane when the MCP's own environment is stripped means +walking the process tree: a launcher (e.g. an agent harness) may hold ``TMUX`` / +``TMUX_PANE`` while the ``uv run`` child that became this server does not. Each +reader returns ``None`` on any failure (missing ``/proc``, permission, a dead +pid) so discovery degrades to "not in tmux" rather than raising -- matching the +lenient list-accessor contract. Only ``TMUX`` / ``TMUX_PANE`` are ever read from +another process's environment (env-minimisation -- never materialise secrets). +""" + +from __future__ import annotations + +import pathlib +import typing as t + +if t.TYPE_CHECKING: + from collections.abc import Mapping + +#: The only environment keys ever read from another process. +_WANTED = ("TMUX", "TMUX_PANE") + + +def read_proc_environ(pid: int) -> Mapping[str, str] | None: + """Return a process's ``TMUX``/``TMUX_PANE`` env only, or ``None`` on failure. + + ``/proc//environ`` is ``KEY=VAL`` pairs joined by NUL bytes. Any read + error (missing, permission, dead pid -- all ``OSError`` subclasses) yields + ``None``. + """ + try: + raw = pathlib.Path(f"/proc/{pid}/environ").read_bytes() + except OSError: + return None + out: dict[str, str] = {} + for item in raw.split(b"\x00"): + if b"=" not in item: + continue + key, value = item.split(b"=", 1) + name = key.decode(errors="replace") + if name in _WANTED: + out[name] = value.decode(errors="replace") + return out + + +def _ppid_from_stat(data: bytes) -> int | None: + """Parse the parent pid out of ``/proc//stat`` bytes. + + The ``comm`` field (2nd) is paren-wrapped and may itself contain spaces or + parens, so the parse anchors on the *last* ``)``; the fields after it are + space-separated, with state at index 0 and ppid at index 1. + + Examples + -------- + >>> _ppid_from_stat(b"1234 (we ird (name)) S 99 1234 1234 0 -1") + 99 + >>> _ppid_from_stat(b"garbage") is None + True + """ + try: + return int(data[data.rindex(b")") + 1 :].split()[1]) + except (ValueError, IndexError): + return None + + +def read_proc_ppid(pid: int) -> int | None: + """Return a process's parent pid from ``/proc//stat``, or ``None``.""" + try: + data = pathlib.Path(f"/proc/{pid}/stat").read_bytes() + except OSError: + return None + return _ppid_from_stat(data) + + +def read_proc_uid(pid: int) -> int | None: + """Return a process's real uid from ``/proc//status``, or ``None``. + + The ``Uid:`` line is ``real effective saved-set filesystem``; the first + field (the real uid) is what :func:`os.getuid` returns. + """ + try: + for line in pathlib.Path(f"/proc/{pid}/status").read_bytes().splitlines(): + if line.startswith(b"Uid:"): + return int(line.split()[1]) + except (OSError, ValueError, IndexError): + return None + return None diff --git a/src/libtmux/experimental/mcp/vocabulary/_resolve.py b/src/libtmux/experimental/mcp/vocabulary/_resolve.py new file mode 100644 index 000000000..dbbcf29db --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_resolve.py @@ -0,0 +1,418 @@ +"""Shared async resolution helpers for the curated vocabulary. + +The pane-scoped tools need to turn a polymorphic target into a concrete id, find +the window scoping a target, and read a window's panes -- small async steps the +pane/window modules share. Kept here so each category module stays focused on its +verbs. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._caller import ( + CallerContext, + engine_socket, + socket_could_match, + socket_matches, +) +from libtmux.experimental.ops import ( + DisplayMessage, + ListPanes, + SelectPane, + TmuxCommandError, + arun, +) +from libtmux.experimental.ops._types import PaneId, Special, Target + +#: Relative directional special tokens that resolve against the control client, +#: not the caller -- rejected with a hint by :func:`reject_relative_special`. +_RELATIVE_SPECIALS = frozenset({"up-of", "down-of", "left-of", "right-of"}) + +#: tmux ``select-pane`` direction flags for the four geometric directions. +DIR_FLAG: dict[str, t.Literal["U", "D", "L", "R"]] = { + "up": "U", + "down": "D", + "left": "L", + "right": "R", +} + + +def opt_target(target: str | Target | None) -> Target | None: + """Resolve an optional target, preserving ``None``.""" + return None if target is None else resolve_target(target) + + +async def pane_id( + engine: AsyncTmuxEngine, + target: str | Target, + version: str | None, +) -> str: + """Resolve *target* to a concrete pane id (``%N``).""" + resolved = resolve_target(target) + if isinstance(resolved, PaneId): + return resolved.value + result = await arun( + DisplayMessage(target=resolved, message="#{pane_id}"), + engine, + version=version, + ) + result.raise_for_status() + return result.text.strip() + + +async def window_id( + engine: AsyncTmuxEngine, + target: str | Target | None, + version: str | None, +) -> str: + """Resolve the window id for *target* (or the active window when ``None``).""" + op = ( + DisplayMessage(message="#{window_id}") + if target is None + else DisplayMessage(target=resolve_target(target), message="#{window_id}") + ) + result = await arun(op, engine, version=version) + result.raise_for_status() + return result.text.strip() + + +async def window_rows( + engine: AsyncTmuxEngine, + window: str, + version: str | None, +) -> list[t.Mapping[str, str]]: + """Return the ``list-panes`` rows belonging to *window*, in tmux order.""" + result = await arun(ListPanes(all_panes=True), engine, version=version) + result.raise_for_status() + return [row for row in result.rows if row.get("window_id") == window] + + +async def run_select( + engine: AsyncTmuxEngine, + op: SelectPane, + version: str | None, +) -> None: + """Run a ``select-pane`` op and raise on failure.""" + (await arun(op, engine, version=version)).raise_for_status() + + +async def select_directional( + engine: AsyncTmuxEngine, + target: str | Target | None, + flag: t.Literal["U", "D", "L", "R"], + version: str | None, +) -> None: + """Move the selection one pane in a tmux direction.""" + await run_select( + engine, SelectPane(target=opt_target(target), direction=flag), version + ) + + +async def select_step( + engine: AsyncTmuxEngine, + target: str | Target | None, + direction: t.Literal["next", "previous"], + version: str | None, +) -> None: + """Select the next/previous pane by absolute id (tmux-version robust).""" + rows = await window_rows(engine, await window_id(engine, target, version), version) + if not rows: + return + ids = [row.get("pane_id", "") for row in rows] + active = next( + (row.get("pane_id", "") for row in rows if row.get("pane_active") == "1"), + ids[0], + ) + step = 1 if direction == "next" else -1 + target_id = ids[(ids.index(active) + step) % len(ids)] + await run_select(engine, SelectPane(target=PaneId(target_id)), version) + + +async def active_pane_id( + engine: AsyncTmuxEngine, + target: str | Target | None, + version: str | None, +) -> str | None: + """Return the active pane id of the window scoping *target*.""" + rows = await window_rows(engine, await window_id(engine, target, version), version) + for row in rows: + if row.get("pane_active") == "1": + return row.get("pane_id") + return rows[0].get("pane_id") if rows else None + + +async def resolve_origin( + engine: AsyncTmuxEngine, + origin: str | Target | None, + version: str | None, +) -> str: + """Resolve a caller-relative origin to a concrete pane id. + + An explicit *origin* is resolved as a target; ``origin=None`` means the + caller's own pane (from the discovered caller context -- own env, an explicit + override, or a ``/proc`` parent walk), socket-scoped exactly like + :func:`~._caller.is_strict_caller` because a ``%N`` is unique only within one + server. When there is no trustworthy caller pane -- the server is not inside + tmux, or its pane belongs to a different server than this engine targets -- + this raises rather than guessing the active pane, so the caller must pass an + explicit origin. + """ + if origin is not None: + return await pane_id(engine, origin, version) + caller = caller_of(engine) + if caller.pane_id and socket_matches(engine_socket(engine), caller): + return caller.pane_id + raise_target_hint( + "no caller pane is available (this MCP is not inside the engine's tmux " + "server); pass an explicit origin pane id (e.g. %3) -- list_panes shows " + "the current panes", + ) + + +def raise_target_hint(message: str) -> t.NoReturn: + """Raise a user-facing tool error (``ToolError`` when fastmcp is present). + + Falls back to :class:`ValueError` so the guard is testable on the sync + surface without the ``mcp`` extra installed. + """ + try: + from fastmcp.exceptions import ToolError + except ImportError: + raise ValueError(message) from None + raise ToolError(message) + + +def reject_relative_special(resolved: Target | None) -> None: + """Raise a targeted hint if *resolved* is a relative directional special. + + ``{up-of}`` / ``{down-of}`` / ``{left-of}`` / ``{right-of}`` resolve against + this MCP's own control-mode client, not the caller's pane, so passing one to + a capture/grep/send tool silently targets the wrong pane. Anchor specials + (``{marked}`` / ``{last}`` / ``{mouse}``) are left untouched. + """ + if ( + isinstance(resolved, Special) + and resolved.token.strip("{}").lower() in _RELATIVE_SPECIALS + ): + raise_target_hint( + f"relative special target {resolved.token} resolves against this MCP's " + "control-mode client, not your pane; resolve_relative_pane(direction=...) " + "and pass the returned %N to your capture/grep/send tool, or use the " + "composed capture_relative_pane / grep_relative_pane (origin defaults to " + "your pane)", + ) + + +def caller_of(engine: AsyncTmuxEngine) -> CallerContext: + """Return the caller context discovered at build time (stashed on the engine). + + Falls back to a fresh :meth:`~._caller.CallerContext.from_env` read when no + context was stashed (the engine was built outside the adapter). + """ + stashed = getattr(engine, "_caller_context", None) + if isinstance(stashed, CallerContext): + return stashed + return CallerContext.from_env() + + +async def session_id_of( + engine: AsyncTmuxEngine, + target: str | Target, + version: str | None, +) -> str: + """Return the session id (``$N``) containing *target* (a pane/window/session).""" + result = await arun( + DisplayMessage(target=resolve_target(target), message="#{session_id}"), + engine, + version=version, + ) + result.raise_for_status() + return result.text.strip() + + +async def caller_window_or_none( + engine: AsyncTmuxEngine, + caller_pane: str, + version: str | None, +) -> str | None: + """Map the caller's pane to its window, or ``None`` if not on this server. + + Fails safe: a caller pane that does not exist on the engine's server (the + cross-server case the conservative gate tolerates) is *not* a self-kill, so + the lookup returns ``None`` rather than surfacing a raw tmux error. + """ + try: + return await window_id(engine, PaneId(caller_pane), version) + except TmuxCommandError: + return None + + +async def caller_session_or_none( + engine: AsyncTmuxEngine, + caller_pane: str, + version: str | None, +) -> str | None: + """Map the caller's pane to its session, or ``None`` if not on this server.""" + try: + return await session_id_of(engine, PaneId(caller_pane), version) + except TmuxCommandError: + return None + + +async def conservative_socket( + engine: AsyncTmuxEngine, + version: str | None, +) -> str | None: + """Resolve the engine's socket for a conservative caller comparison. + + An explicit ``-S`` path is authoritative as-is. For a ``-L`` name or the + ambient socket, asks tmux for ``#{socket_path}`` -- the path tmux actually + uses -- so a macOS ``$TMUX_TMPDIR`` divergence under launchd cannot fool the + reconstruction; falls back to the static selector when the query fails. + """ + static = engine_socket(engine) + if static is not None and "/" in static: + return static + try: + result = await arun( + DisplayMessage(message="#{socket_path}"), + engine, + version=version, + ) + result.raise_for_status() + except TmuxCommandError: + return static + return result.text.strip() or static + + +async def guard_self_kill( + engine: AsyncTmuxEngine, + *, + pane: str | None = None, + window: str | None = None, + session: str | None = None, + version: str | None = None, +) -> None: + """Refuse a destructive op aimed at the caller's own pane/window/session. + + Socket-scoped first (``%N``/``@N``/``$N`` are per-server counters that + collide across servers), then the caller's pane is mapped to its window / + session *via the engine* (``$TMUX`` carries no window id). Uses the + conservative comparator so a self-kill fails safe under socket uncertainty; + a caller pane absent from this engine's server fails safe to *allow* (it is + not a self-kill). Raises a refusal hint; a different pane/window/session, or a + cross-socket target with the same id, is not refused. + """ + caller = caller_of(engine) + if not caller.in_tmux or caller.pane_id is None: + return + if not socket_could_match(await conservative_socket(engine, version), caller): + return + if pane is not None and caller.pane_id == pane: + raise_target_hint( + f"refusing to kill pane {pane}: it runs this MCP server. Target a " + "different pane, or run the tmux command manually if intended.", + ) + if window is not None: + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is not None and caller_window == window: + raise_target_hint( + f"refusing to kill window {window}: it holds this MCP server's " + "pane. Use a manual tmux command if intended.", + ) + if session is not None: + caller_session = await caller_session_or_none(engine, caller.pane_id, version) + if caller_session is not None and caller_session == session: + raise_target_hint( + f"refusing to kill session {session}: it holds this MCP server's " + "pane. Use a manual tmux command if intended.", + ) + + +async def guard_kill_other_panes( + engine: AsyncTmuxEngine, + target_pane: str, + version: str | None, +) -> None: + """Refuse ``kill_pane(others=True)`` when the caller is a sibling of the target. + + ``others=True`` keeps the target and kills every other pane in its window, so + the danger is the caller pane being one of those siblings (not the target). + """ + caller = caller_of(engine) + if not caller.in_tmux or not caller.pane_id or caller.pane_id == target_pane: + return + if not socket_could_match(await conservative_socket(engine, version), caller): + return + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is None: + return + target_window = await window_id(engine, PaneId(target_pane), version) + if caller_window == target_window: + raise_target_hint( + f"refusing to kill the other panes of window {target_window}: pane " + f"{caller.pane_id} runs this MCP server. Kill panes individually " + f"(excluding {caller.pane_id}), or run the tmux command manually.", + ) + + +async def guard_kill_other_windows( + engine: AsyncTmuxEngine, + target: str | Target, + target_window: str, + version: str | None, +) -> None: + """Refuse ``kill_window(others=True)`` when the caller is a same-session sibling. + + ``others=True`` keeps the target window and kills every other window in its + session, so the danger is the caller's window being one of those siblings. + """ + caller = caller_of(engine) + if not caller.in_tmux or not caller.pane_id: + return + if not socket_could_match(await conservative_socket(engine, version), caller): + return + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is None or caller_window == target_window: + return # caller not on this server, or it is the kept target window + target_session = await session_id_of(engine, target, version) + caller_session = await caller_session_or_none(engine, caller.pane_id, version) + if caller_session is None or caller_session != target_session: + return + raise_target_hint( + f"refusing to kill the other windows of session {caller_session}: window " + f"{caller_window} holds this MCP server's pane {caller.pane_id}. Exclude " + "it, or run the tmux command manually.", + ) + + +async def guard_destructive_op(engine: AsyncTmuxEngine, operation: t.Any) -> None: + """Apply the self-kill guard to a per-op kill/respawn operation, by kind. + + Covers the ``op_*`` per-operation surface, including the ``others=True`` + sibling case (which keeps the target and kills its neighbours). + """ + target = operation.target + if target is None: + return + kind = operation.kind + others = bool(getattr(operation, "others", False)) + if kind == "respawn_pane": + await guard_self_kill(engine, pane=await pane_id(engine, target, None)) + elif kind == "kill_pane": + target_pane = await pane_id(engine, target, None) + if others: + await guard_kill_other_panes(engine, target_pane, None) + else: + await guard_self_kill(engine, pane=target_pane) + elif kind == "kill_window": + target_window = await window_id(engine, target, None) + if others: + await guard_kill_other_windows(engine, target, target_window, None) + else: + await guard_self_kill(engine, window=target_window) + elif kind == "kill_session": + await guard_self_kill(engine, session=await session_id_of(engine, target, None)) diff --git a/src/libtmux/experimental/mcp/vocabulary/_results.py b/src/libtmux/experimental/mcp/vocabulary/_results.py new file mode 100644 index 000000000..8ef74f516 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_results.py @@ -0,0 +1,106 @@ +"""Small, typed result values returned by the curated vocabulary. + +Each curated tool returns one of these frozen dataclasses exposing just the +ids/names/lines a caller cares about -- never a live ORM object and never the raw +:class:`~libtmux.experimental.ops.results.Result`. They serialize trivially +(plain scalars and tuples), which is what the MCP edge hands back to an agent. +""" + +from __future__ import annotations + +import collections.abc +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SessionResult: + """A created session: its id, name, and captured first window/pane ids.""" + + session_id: str + name: str | None = None + first_window_id: str | None = None + first_pane_id: str | None = None + + +@dataclass(frozen=True) +class WindowResult: + """A created window: its id, name, and captured first pane id.""" + + window_id: str + name: str | None = None + first_pane_id: str | None = None + + +@dataclass(frozen=True) +class PaneResult: + """A created pane: its id.""" + + pane_id: str + + +@dataclass(frozen=True) +class PaneRef: + """A resolved pane id (or ``None`` when no pane matched the query).""" + + pane_id: str | None + + +@dataclass(frozen=True) +class PaneCapture: + """Captured pane contents.""" + + lines: tuple[str, ...] + + +@dataclass(frozen=True) +class Listing: + """A list query result: one mapping (tmux format row) per object.""" + + rows: tuple[collections.abc.Mapping[str, str], ...] + + +@dataclass(frozen=True) +class OptionMap: + """Parsed ``show-options`` output: ``name -> value`` pairs.""" + + options: collections.abc.Mapping[str, str] + + +@dataclass(frozen=True) +class MessageText: + """The formatted text of a ``display-message -p`` query.""" + + text: str + + +@dataclass(frozen=True) +class BufferText: + """The contents of a paste buffer (``show-buffer``).""" + + text: str + + +@dataclass(frozen=True) +class RawResult: + """The raw outcome of a passthrough ``run_tmux`` invocation.""" + + ok: bool + returncode: int + stdout: tuple[str, ...] + stderr: tuple[str, ...] + + +@dataclass(frozen=True) +class PaneMatch: + """One pane whose terminal text matched a search, with its caller flag.""" + + pane_id: str + is_caller: bool + lines: tuple[str, ...] + + +@dataclass(frozen=True) +class PaneSearch: + """The panes whose scrollback matched a ``search_panes`` query.""" + + matches: tuple[PaneMatch, ...] diff --git a/src/libtmux/experimental/mcp/vocabulary/buffer.py b/src/libtmux/experimental/mcp/vocabulary/buffer.py new file mode 100644 index 000000000..1f18307ed --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/buffer.py @@ -0,0 +1,66 @@ +"""Paste-buffer vocabulary: set, show, paste.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import BufferText +from libtmux.experimental.ops import PasteBuffer, SetBuffer, ShowBuffer, arun +from libtmux.experimental.ops._types import Target + + +async def aset_buffer( + engine: AsyncTmuxEngine, + data: str, + *, + buffer_name: str | None = None, + version: str | None = None, +) -> None: + """Set a paste buffer's contents (``set-buffer``).""" + ( + await arun( + SetBuffer(data=data, buffer_name=buffer_name), + engine, + version=version, + ) + ).raise_for_status() + + +async def ashow_buffer( + engine: AsyncTmuxEngine, + *, + buffer_name: str | None = None, + version: str | None = None, +) -> BufferText: + """Return a paste buffer's contents (``show-buffer``).""" + result = await arun(ShowBuffer(buffer_name=buffer_name), engine, version=version) + result.raise_for_status() + return BufferText(text=result.text) + + +async def apaste_buffer( + engine: AsyncTmuxEngine, + target: str | Target, + *, + buffer_name: str | None = None, + delete: bool = False, + version: str | None = None, +) -> None: + """Paste a buffer into a pane (``paste-buffer``).""" + ( + await arun( + PasteBuffer( + target=resolve_target(target), + buffer_name=buffer_name, + delete=delete, + ), + engine, + version=version, + ) + ).raise_for_status() + + +set_buffer = synced(aset_buffer) +show_buffer = synced(ashow_buffer) +paste_buffer = synced(apaste_buffer) diff --git a/src/libtmux/experimental/mcp/vocabulary/option.py b/src/libtmux/experimental/mcp/vocabulary/option.py new file mode 100644 index 000000000..52f4787a7 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/option.py @@ -0,0 +1,73 @@ +"""Option vocabulary: show and set tmux options.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import OptionMap +from libtmux.experimental.ops import SetOption, ShowOptions, arun +from libtmux.experimental.ops._types import Target + + +def _opt(target: str | Target | None) -> Target | None: + """Resolve an optional target, preserving ``None``.""" + return None if target is None else resolve_target(target) + + +async def ashow_options( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + global_: bool = False, + server: bool = False, + window: bool = False, + version: str | None = None, +) -> OptionMap: + """Show tmux options as ``name -> value`` pairs (``show-options``).""" + result = await arun( + ShowOptions( + target=_opt(target), + global_=global_, + server=server, + window=window, + ), + engine, + version=version, + ) + result.raise_for_status() + return OptionMap(options=result.options) + + +async def aset_option( + engine: AsyncTmuxEngine, + option: str, + value: str | None = None, + target: str | Target | None = None, + *, + global_: bool = False, + server: bool = False, + window: bool = False, + unset: bool = False, + version: str | None = None, +) -> None: + """Set (or unset) a tmux option (``set-option``).""" + ( + await arun( + SetOption( + target=_opt(target), + option=option, + value=value, + global_=global_, + server=server, + window=window, + unset=unset, + ), + engine, + version=version, + ) + ).raise_for_status() + + +show_options = synced(ashow_options) +set_option = synced(aset_option) diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py new file mode 100644 index 000000000..032312835 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -0,0 +1,612 @@ +"""Pane-scope vocabulary: split, send, capture, resize, swap/join/break, select. + +Beyond thin op wrappers, this module hosts the composed, caller-aware +conveniences an agent reaches for that raw tmux makes awkward: +``capture_active_pane`` (no target), ``grep_pane`` (capture + filter, since tmux +has no server-side grep), ``search_panes`` ("which pane shows X?"), and the +geometry-resolved ``resolve_relative_pane`` / ``capture_relative_pane`` / +``grep_relative_pane`` / ``find_pane_by_position`` / directional ``select_pane``. +The relative tools resolve layout geometry to a concrete ``%N`` (robust across +tmux versions) and default their origin to the *caller's* pane; every +single-target tool that could act on the wrong pane rejects a relative special +target (``{up-of}`` …) with a hint, because those resolve against this MCP's +control client, not the caller. +""" + +from __future__ import annotations + +import re +import typing as t + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._caller import ( + engine_socket, + is_strict_caller, +) +from libtmux.experimental.mcp.vocabulary._geometry import ( + Corner, + Direction, + corner_pane, + neighbor, + parse_boxes, +) +from libtmux.experimental.mcp.vocabulary._resolve import ( + DIR_FLAG, + active_pane_id, + caller_of, + guard_kill_other_panes, + guard_self_kill, + opt_target, + pane_id, + raise_target_hint, + reject_relative_special, + resolve_origin, + run_select, + select_directional, + select_step, + window_id, + window_rows, +) +from libtmux.experimental.mcp.vocabulary._results import ( + Listing, + PaneCapture, + PaneMatch, + PaneRef, + PaneResult, + PaneSearch, + WindowResult, +) +from libtmux.experimental.ops import ( + BreakPane, + CapturePane, + JoinPane, + KillPane, + ListPanes, + ResizePane, + RespawnPane, + SelectPane, + SendKeys, + SplitWindow, + SwapPane, + arun, +) +from libtmux.experimental.ops._types import PaneId, Target + +#: Default ceiling on the panes ``search_panes`` captures, to bound fan-out cost. +_SEARCH_PANE_CAP = 200 + + +def _compile(pattern: str, *, ignore_case: bool) -> re.Pattern[str]: + """Compile a user-supplied regex, routing a bad pattern to a tool hint.""" + try: + return re.compile(pattern, re.IGNORECASE if ignore_case else 0) + except re.error as error: + raise_target_hint(f"invalid search pattern {pattern!r}: {error}") + + +async def asplit_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + horizontal: bool = False, + start_directory: str | None = None, + version: str | None = None, +) -> PaneResult: + """Split a pane, creating a new one (mirrors ``window.split_window``).""" + result = await arun( + SplitWindow( + target=resolve_target(target), + horizontal=horizontal, + start_directory=start_directory, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneResult(pane_id=result.new_pane_id or "") + + +async def asend_input( + engine: AsyncTmuxEngine, + target: str | Target, + keys: str, + *, + enter: bool = False, + literal: bool = False, + suppress_history: bool = False, + version: str | None = None, +) -> None: + """Send keys to a pane (mirrors ``pane.send_keys``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + ( + await arun( + SendKeys( + target=resolved, + keys=keys, + enter=enter, + literal=literal, + suppress_history=suppress_history, + ), + engine, + version=version, + ) + ).raise_for_status() + + +async def acapture_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture one pane's terminal text (mirrors ``pane.capture_pane``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + result = await arun( + CapturePane( + target=resolved, + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=result.lines) + + +async def acapture_active_pane( + engine: AsyncTmuxEngine, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture the active pane with no explicit target (current client). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> capture_active_pane(ConcreteEngine(capture_lines=("hi",))).lines + ('hi',) + """ + result = await arun( + CapturePane( + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=result.lines) + + +async def agrep_pane( + engine: AsyncTmuxEngine, + target: str | Target, + pattern: str, + *, + ignore_case: bool = True, + start: int | None = None, + version: str | None = None, +) -> PaneCapture: + """Search one pane's terminal text (scrollback), returning matching lines. + + tmux has no server-side grep, so this captures (joining wrapped lines so a + match is not split across a hard wrap) and filters client-side. Matching is + case-insensitive by default. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> engine = ConcreteEngine(capture_lines=("foo", "bar baz", "foobar")) + >>> grep_pane(engine, "%1", "foo").lines + ('foo', 'foobar') + """ + resolved = resolve_target(target) + reject_relative_special(resolved) + matcher = _compile(pattern, ignore_case=ignore_case) + result = await arun( + CapturePane(target=resolved, start=start, join_wrapped=True), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=tuple(ln for ln in result.lines if matcher.search(ln))) + + +async def asearch_panes( + engine: AsyncTmuxEngine, + pattern: str, + *, + ignore_case: bool = True, + start: int | None = None, + max_panes: int = _SEARCH_PANE_CAP, + version: str | None = None, +) -> PaneSearch: + """Search every pane's terminal text; return the panes that match. + + Answers "which pane shows X?" by capturing each pane's scrollback and + filtering client-side (tmux has no cross-pane search). Each match is flagged + ``is_caller`` when it is the pane that launched this MCP. Captures are + serial, so this is bounded to the first ``max_panes`` panes; panes whose + capture fails are skipped (lenient, like the list accessors). + """ + matcher = _compile(pattern, ignore_case=ignore_case) + listing = await arun(ListPanes(all_panes=True), engine, version=version) + listing.raise_for_status() + caller = caller_of(engine) + socket = engine_socket(engine) + matches: list[PaneMatch] = [] + for row in listing.rows[:max_panes]: + pid = row.get("pane_id", "") + if not pid: + continue + cap = await arun( + CapturePane(target=PaneId(pid), start=start, join_wrapped=True), + engine, + version=version, + ) + if not cap.ok: + continue + lines = tuple(ln for ln in cap.lines if matcher.search(ln)) + if lines: + matches.append( + PaneMatch( + pane_id=pid, + is_caller=is_strict_caller(pid, socket, caller), + lines=lines, + ), + ) + return PaneSearch(matches=tuple(matches)) + + +async def aresize_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + width: int | None = None, + height: int | None = None, + zoom: bool = False, + version: str | None = None, +) -> None: + """Resize a pane, or toggle its zoom (``resize-pane``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + ( + await arun( + ResizePane(target=resolved, width=width, height=height, zoom=zoom), + engine, + version=version, + ) + ).raise_for_status() + + +async def aswap_pane( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + version: str | None = None, +) -> None: + """Swap two panes (``swap-pane``: ``-s`` source, ``-t`` destination).""" + src_target = resolve_target(src) + dst_target = resolve_target(dst) + reject_relative_special(src_target) + reject_relative_special(dst_target) + ( + await arun( + SwapPane(target=dst_target, src_target=src_target), + engine, + version=version, + ) + ).raise_for_status() + + +async def ajoin_pane( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + horizontal: bool = False, + size: int | None = None, + version: str | None = None, +) -> None: + """Join a source pane into a destination window/pane (``join-pane``).""" + src_target = resolve_target(src) + dst_target = resolve_target(dst) + reject_relative_special(src_target) + reject_relative_special(dst_target) + ( + await arun( + JoinPane( + target=dst_target, + src_target=src_target, + horizontal=horizontal, + size=size, + ), + engine, + version=version, + ) + ).raise_for_status() + + +async def abreak_pane( + engine: AsyncTmuxEngine, + src: str | Target, + *, + name: str | None = None, + version: str | None = None, +) -> WindowResult: + """Break a pane out into a new window (``break-pane``); return its id.""" + src_target = resolve_target(src) + reject_relative_special(src_target) + result = await arun( + BreakPane(src_target=src_target, name=name), + engine, + version=version, + ) + result.raise_for_status() + return WindowResult(window_id=result.new_id or "", name=name) + + +async def arespawn_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + kill: bool = False, + shell: str | None = None, + start_directory: str | None = None, + version: str | None = None, +) -> None: + """Restart a pane's process in place (``respawn-pane``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + await guard_self_kill( + engine, pane=await pane_id(engine, target, version), version=version + ) + ( + await arun( + RespawnPane( + target=resolved, + kill=kill, + shell=shell, + start_directory=start_directory, + ), + engine, + version=version, + ) + ).raise_for_status() + + +async def akill_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + others: bool = False, + version: str | None = None, +) -> None: + """Kill a pane (or all others in its window with ``others=True``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + target_pane = await pane_id(engine, target, version) + if others: + await guard_kill_other_panes(engine, target_pane, version) + else: + await guard_self_kill(engine, pane=target_pane, version=version) + ( + await arun( + KillPane(target=resolved, others=others), + engine, + version=version, + ) + ).raise_for_status() + + +async def alist_panes( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + all_panes: bool = False, + version: str | None = None, +) -> Listing: + """List panes (metadata), flagging the caller's own pane with ``is_caller``. + + Mirrors ``window.panes``; each row gains an ``is_caller`` field (``"1"`` for + the pane that launched this MCP, else ``"0"`` -- the same ``"1"``/``"0"`` + convention as tmux's own ``pane_active``). + """ + result = await arun(ListPanes(all_panes=all_panes), engine, version=version) + result.raise_for_status() + caller = caller_of(engine) + socket = engine_socket(engine) + rows = tuple( + { + **row, + "is_caller": "1" + if is_strict_caller(row.get("pane_id"), socket, caller) + else "0", + } + for row in result.rows + ) + return Listing(rows=rows) + + +async def aselect_pane( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + direction: t.Literal["up", "down", "left", "right", "last", "next", "previous"] + | None = None, + version: str | None = None, +) -> PaneRef: + """Focus a pane by id or relative *direction*; return the now-active pane. + + ``up``/``down``/``left``/``right`` use tmux's own directional select; ``last`` + re-selects the previously active pane; ``next``/``previous`` step by pane + order, computed from absolute ids to sidestep tmux-version target quirks. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> select_pane(ConcreteEngine(), "%1", direction="left") + PaneRef(pane_id=None) + """ + if direction in DIR_FLAG: + await select_directional(engine, target, DIR_FLAG[direction], version) + elif direction == "last": + await run_select( + engine, SelectPane(target=opt_target(target), last=True), version + ) + elif direction in ("next", "previous"): + await select_step(engine, target, direction, version) + elif target is not None: + resolved = resolve_target(target) + reject_relative_special(resolved) + await run_select(engine, SelectPane(target=resolved), version) + return PaneRef(pane_id=await active_pane_id(engine, target, version)) + + +async def aresolve_relative_pane( + engine: AsyncTmuxEngine, + direction: Direction, + origin: str | Target | None = None, + *, + version: str | None = None, +) -> PaneRef: + """Return the id of the pane *direction* of *origin* (caller pane by default). + + Resolved from layout geometry (the ``pane_left/top/right/bottom`` the list + template already carries) -- robust across tmux versions, and without moving + the active pane. ``origin=None`` means the caller's own pane (the pane that + launched this MCP), resolved only when this engine targets the caller's tmux + server; otherwise an explicit ``origin`` is required. This never falls back to + tmux's active pane (the control client's cursor, not the caller). + """ + origin_id = await resolve_origin(engine, origin, version) + if not origin_id: + return PaneRef(pane_id=None) + window = await window_id(engine, origin_id, version) + boxes = parse_boxes(await window_rows(engine, window, version)) + return PaneRef(pane_id=neighbor(boxes, origin_id, direction)) + + +async def acapture_relative_pane( + engine: AsyncTmuxEngine, + direction: Direction, + origin: str | Target | None = None, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture the pane *direction* of *origin* (caller pane by default). + + Resolves the neighbour to a concrete ``%N`` first, so it never hands tmux a + relative special target. Raises a hint (naming the resolved origin) when + there is no pane that way. + """ + ref = await aresolve_relative_pane(engine, direction, origin, version=version) + if ref.pane_id is None: + await _raise_no_neighbour(engine, direction, origin, version) + return await acapture_pane( + engine, + PaneId(ref.pane_id or ""), + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + version=version, + ) + + +async def agrep_relative_pane( + engine: AsyncTmuxEngine, + direction: Direction, + pattern: str, + origin: str | Target | None = None, + *, + ignore_case: bool = True, + start: int | None = None, + version: str | None = None, +) -> PaneCapture: + """Search the terminal text of the pane *direction* of *origin* (caller default). + + The one-call answer to "what does the pane above/below/beside me show?". + Resolves the neighbour to a concrete ``%N`` first; raises a hint (naming the + resolved origin) when there is no pane that way. + """ + ref = await aresolve_relative_pane(engine, direction, origin, version=version) + if ref.pane_id is None: + await _raise_no_neighbour(engine, direction, origin, version) + return await agrep_pane( + engine, + PaneId(ref.pane_id or ""), + pattern, + ignore_case=ignore_case, + start=start, + version=version, + ) + + +async def afind_pane_by_position( + engine: AsyncTmuxEngine, + corner: Corner, + target: str | Target | None = None, + *, + version: str | None = None, +) -> PaneRef: + """Return the id of the pane occupying *corner* of a window.""" + window = await window_id(engine, target, version) + boxes = parse_boxes(await window_rows(engine, window, version)) + return PaneRef(pane_id=corner_pane(boxes, corner)) + + +async def _raise_no_neighbour( + engine: AsyncTmuxEngine, + direction: Direction, + origin: str | Target | None, + version: str | None, +) -> t.NoReturn: + """Raise a no-neighbour hint naming the concrete origin pane.""" + origin_id = await resolve_origin(engine, origin, version) + where = origin_id or "the caller pane" + raise_target_hint( + f"no pane {direction} of {where}; see list_panes for the current layout", + ) + + +split_pane = synced(asplit_pane) +send_input = synced(asend_input) +capture_pane = synced(acapture_pane) +capture_active_pane = synced(acapture_active_pane) +grep_pane = synced(agrep_pane) +search_panes = synced(asearch_panes) +resize_pane = synced(aresize_pane) +swap_pane = synced(aswap_pane) +join_pane = synced(ajoin_pane) +break_pane = synced(abreak_pane) +respawn_pane = synced(arespawn_pane) +kill_pane = synced(akill_pane) +list_panes = synced(alist_panes) +select_pane = synced(aselect_pane) +resolve_relative_pane = synced(aresolve_relative_pane) +capture_relative_pane = synced(acapture_relative_pane) +grep_relative_pane = synced(agrep_relative_pane) +find_pane_by_position = synced(afind_pane_by_position) diff --git a/src/libtmux/experimental/mcp/vocabulary/server.py b/src/libtmux/experimental/mcp/vocabulary/server.py new file mode 100644 index 000000000..d4f166746 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/server.py @@ -0,0 +1,71 @@ +"""Server-scope vocabulary: list clients, display-message, and the raw escape hatch.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine, CommandRequest +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import Listing, MessageText, RawResult +from libtmux.experimental.ops import DisplayMessage, ListClients, arun +from libtmux.experimental.ops._types import Target + + +async def alist_clients( + engine: AsyncTmuxEngine, + *, + version: str | None = None, +) -> Listing: + """List attached clients (``list-clients``).""" + result = await arun(ListClients(), engine, version=version) + result.raise_for_status() + return Listing(rows=result.rows) + + +async def adisplay_message( + engine: AsyncTmuxEngine, + target: str | Target, + message: str, + *, + version: str | None = None, +) -> MessageText: + """Expand a tmux format string against *target* (``display-message -p``).""" + result = await arun( + DisplayMessage(target=resolve_target(target), message=message), + engine, + version=version, + ) + result.raise_for_status() + return MessageText(text=result.text) + + +async def arun_tmux( + engine: AsyncTmuxEngine, + args: list[str], + *, + version: str | None = None, +) -> RawResult: + """Run an arbitrary tmux command (the guarded raw escape hatch). + + Returns the structured outcome without raising on a tmux-side failure -- a + nonzero exit or stderr is reported in :class:`~._results.RawResult`. This is + deliberately *not* read-only; servers exposed on a network transport should + gate it more strictly than local ones. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> run_tmux(ConcreteEngine(), ["list-sessions"]).ok + True + """ + raw = await engine.run(CommandRequest.from_args(*args)) + return RawResult( + ok=raw.returncode == 0 and not raw.stderr, + returncode=raw.returncode, + stdout=tuple(raw.stdout), + stderr=tuple(raw.stderr), + ) + + +list_clients = synced(alist_clients) +display_message = synced(adisplay_message) +run_tmux = synced(arun_tmux) diff --git a/src/libtmux/experimental/mcp/vocabulary/session.py b/src/libtmux/experimental/mcp/vocabulary/session.py new file mode 100644 index 000000000..e5e0e65e1 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/session.py @@ -0,0 +1,137 @@ +"""Session-scope vocabulary: create, rename, kill, list, existence. + +Each tool is written once as an ``async def`` over an +:class:`~libtmux.experimental.engines.base.AsyncTmuxEngine`; the public sync name +is a :func:`~._bridge.synced` twin. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._resolve import guard_self_kill, session_id_of +from libtmux.experimental.mcp.vocabulary._results import Listing, SessionResult +from libtmux.experimental.ops import ( + HasSession, + KillSession, + ListSessions, + NewSession, + RenameSession, + arun, +) +from libtmux.experimental.ops._types import Target + + +async def acreate_session( + engine: AsyncTmuxEngine, + *, + name: str | None = None, + start_directory: str | None = None, + environment: t.Mapping[str, str] | None = None, + width: int | None = None, + height: int | None = None, + version: str | None = None, +) -> SessionResult: + """Create a detached session (mirrors ``server.new_session``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> r = create_session(ConcreteEngine(), name="work") + >>> (r.session_id, r.name, r.first_pane_id) + ('$1', 'work', '%1') + """ + result = await arun( + NewSession( + session_name=name, + start_directory=start_directory, + environment=environment, + width=width, + height=height, + capture_panes=True, + ), + engine, + version=version, + ) + result.raise_for_status() + return SessionResult( + session_id=result.new_id or "", + name=name, + first_window_id=result.first_window_id, + first_pane_id=result.first_pane_id, + ) + + +async def arename_session( + engine: AsyncTmuxEngine, + target: str | Target, + name: str, + *, + version: str | None = None, +) -> None: + """Rename a session (mirrors ``session.rename_session``).""" + ( + await arun( + RenameSession(target=resolve_target(target), name=name), + engine, + version=version, + ) + ).raise_for_status() + + +async def akill_session( + engine: AsyncTmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> None: + """Kill a session (mirrors ``session.kill``).""" + target_session = await session_id_of(engine, target, version) + await guard_self_kill(engine, session=target_session, version=version) + ( + await arun(KillSession(target=resolve_target(target)), engine, version=version) + ).raise_for_status() + + +async def alist_sessions( + engine: AsyncTmuxEngine, + *, + version: str | None = None, +) -> Listing: + """List the server's sessions (mirrors ``server.sessions``).""" + result = await arun(ListSessions(), engine, version=version) + result.raise_for_status() + return Listing(rows=result.rows) + + +async def ahas_session( + engine: AsyncTmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> bool: + """Return whether a session exists (``has-session``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> has_session(ConcreteEngine(), "$1") + True + """ + result = await arun( + HasSession(target=resolve_target(target)), + engine, + version=version, + ) + result.raise_for_status() + return result.exists + + +create_session = synced(acreate_session) +rename_session = synced(arename_session) +kill_session = synced(akill_session) +list_sessions = synced(alist_sessions) +has_session = synced(ahas_session) diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py new file mode 100644 index 000000000..dc10b2fc5 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -0,0 +1,193 @@ +"""Window-scope vocabulary: create, rename, select, move, swap, kill, list, layout.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._resolve import ( + guard_kill_other_windows, + guard_self_kill, + window_id, +) +from libtmux.experimental.mcp.vocabulary._results import Listing, WindowResult +from libtmux.experimental.ops import ( + KillWindow, + ListWindows, + MoveWindow, + NewWindow, + RenameWindow, + SelectLayout, + SelectWindow, + SwapWindow, + arun, +) +from libtmux.experimental.ops._types import Target + + +async def acreate_window( + engine: AsyncTmuxEngine, + target: str | Target, + *, + name: str | None = None, + start_directory: str | None = None, + version: str | None = None, +) -> WindowResult: + """Create a window in a session (mirrors ``session.new_window``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> w = create_window(ConcreteEngine(), "$1", name="logs") + >>> w.window_id.startswith("@"), w.name + (True, 'logs') + """ + result = await arun( + NewWindow( + target=resolve_target(target), + name=name, + start_directory=start_directory, + capture_pane=True, + ), + engine, + version=version, + ) + result.raise_for_status() + return WindowResult( + window_id=result.new_id or "", + name=name, + first_pane_id=result.first_pane_id, + ) + + +async def arename_window( + engine: AsyncTmuxEngine, + target: str | Target, + name: str, + *, + version: str | None = None, +) -> None: + """Rename a window (mirrors ``window.rename_window``).""" + ( + await arun( + RenameWindow(target=resolve_target(target), name=name), + engine, + version=version, + ) + ).raise_for_status() + + +async def aselect_window( + engine: AsyncTmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> None: + """Make a window active (mirrors ``window.select_window``).""" + ( + await arun(SelectWindow(target=resolve_target(target)), engine, version=version) + ).raise_for_status() + + +async def amove_window( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + version: str | None = None, +) -> None: + """Move a window to a new index/session (``move-window``).""" + ( + await arun( + MoveWindow(target=resolve_target(dst), src_target=resolve_target(src)), + engine, + version=version, + ) + ).raise_for_status() + + +async def aswap_window( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + version: str | None = None, +) -> None: + """Swap two windows (``swap-window``).""" + ( + await arun( + SwapWindow(target=resolve_target(dst), src_target=resolve_target(src)), + engine, + version=version, + ) + ).raise_for_status() + + +async def akill_window( + engine: AsyncTmuxEngine, + target: str | Target, + *, + others: bool = False, + version: str | None = None, +) -> None: + """Kill a window (or all others in its session with ``others=True``).""" + resolved = resolve_target(target) + target_window = await window_id(engine, resolved, version) + if others: + await guard_kill_other_windows(engine, target, target_window, version) + else: + await guard_self_kill(engine, window=target_window, version=version) + ( + await arun( + KillWindow(target=resolved, others=others), + engine, + version=version, + ) + ).raise_for_status() + + +async def alist_windows( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + all_windows: bool = False, + version: str | None = None, +) -> Listing: + """List windows of a session, or all windows (mirrors ``session.windows``).""" + result = await arun( + ListWindows( + target=None if target is None else resolve_target(target), + all_windows=all_windows, + ), + engine, + version=version, + ) + result.raise_for_status() + return Listing(rows=result.rows) + + +async def aselect_layout( + engine: AsyncTmuxEngine, + target: str | Target, + *, + layout: str | None = None, + version: str | None = None, +) -> None: + """Apply a layout to a window (mirrors ``window.select_layout``).""" + ( + await arun( + SelectLayout(target=resolve_target(target), layout=layout), + engine, + version=version, + ) + ).raise_for_status() + + +create_window = synced(acreate_window) +rename_window = synced(arename_window) +select_window = synced(aselect_window) +move_window = synced(amove_window) +swap_window = synced(aswap_window) +kill_window = synced(akill_window) +list_windows = synced(alist_windows) +select_layout = synced(aselect_layout) diff --git a/src/libtmux/experimental/models/__init__.py b/src/libtmux/experimental/models/__init__.py new file mode 100644 index 000000000..9f07540f9 --- /dev/null +++ b/src/libtmux/experimental/models/__init__.py @@ -0,0 +1,27 @@ +"""Pure, immutable snapshots of the tmux object graph. + +A neo-like view of server/session/window/pane state as plain *values* -- no live +:class:`~libtmux.Server`, no command dispatch, no coupling to the existing ORM or +query pipeline. Snapshots are immutable, composable into a tree, and serializable, +so they are safe to experiment with under :mod:`libtmux.experimental` without +touching shipped APIs. See the operationalization plan (``tmux-python/libtmux`` +issue 689). +""" + +from __future__ import annotations + +from libtmux.experimental.models.snapshots import ( + ClientSnapshot, + PaneSnapshot, + ServerSnapshot, + SessionSnapshot, + WindowSnapshot, +) + +__all__ = ( + "ClientSnapshot", + "PaneSnapshot", + "ServerSnapshot", + "SessionSnapshot", + "WindowSnapshot", +) diff --git a/src/libtmux/experimental/models/snapshots.py b/src/libtmux/experimental/models/snapshots.py new file mode 100644 index 000000000..4af4652d0 --- /dev/null +++ b/src/libtmux/experimental/models/snapshots.py @@ -0,0 +1,348 @@ +"""Pure, immutable snapshots of the tmux object graph. + +These are *values*, not live objects: a snapshot captures the state of a server, +session, window, or pane at one moment, with no reference to a +:class:`~libtmux.Server` and no ability to issue tmux commands. They resemble +:class:`libtmux.neo.Obj` but are decoupled from the query/dispatch pipeline and +from each other, so experimenting with them cannot affect the existing ORM APIs. + +Each snapshot keeps a typed *core* of the most-used fields plus the full raw +format mapping in :attr:`fields`, so nothing tmux reported is lost. Snapshots +compose into a tree (:class:`ServerSnapshot` → :class:`SessionSnapshot` → +:class:`WindowSnapshot` → :class:`PaneSnapshot`), can be built from a single +``list-panes -a -F`` style row set via :meth:`ServerSnapshot.from_pane_rows`, +and round-trip to plain dicts for serialization or diffing. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + from collections.abc import Iterable, Mapping + +_TRUE = {"1", "on", "yes", "true"} + + +def _as_int(value: str | None) -> int | None: + """Coerce a tmux format value to ``int``, or ``None`` if absent/non-numeric. + + Examples + -------- + >>> _as_int("3") + 3 + >>> _as_int("") is None + True + >>> _as_int(None) is None + True + """ + if value is None or value == "": + return None + try: + return int(value) + except ValueError: + return None + + +def _as_bool(value: str | None) -> bool: + """Coerce a tmux flag value (``"1"``/``"0"``/``""``) to ``bool``. + + Examples + -------- + >>> _as_bool("1") + True + >>> _as_bool("0") + False + >>> _as_bool(None) + False + """ + return value is not None and value.lower() in _TRUE + + +@dataclass(frozen=True) +class PaneSnapshot: + """An immutable snapshot of one tmux pane. + + Examples + -------- + >>> pane = PaneSnapshot.from_format({ + ... "pane_id": "%1", "pane_index": "0", "window_id": "@1", + ... "session_id": "$0", "pane_active": "1", "pane_width": "80", + ... "pane_height": "24", "pane_current_command": "zsh", + ... }) + >>> pane.pane_id, pane.pane_index, pane.active, pane.width + ('%1', 0, True, 80) + >>> pane.current_command + 'zsh' + """ + + pane_id: str = "" + pane_index: int | None = None + window_id: str = "" + session_id: str = "" + active: bool = False + width: int | None = None + height: int | None = None + current_command: str | None = None + current_path: str | None = None + title: str | None = None + pid: int | None = None + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> PaneSnapshot: + """Build a pane snapshot from a raw tmux format mapping.""" + return cls( + pane_id=raw.get("pane_id", ""), + pane_index=_as_int(raw.get("pane_index")), + window_id=raw.get("window_id", ""), + session_id=raw.get("session_id", ""), + active=_as_bool(raw.get("pane_active")), + width=_as_int(raw.get("pane_width")), + height=_as_int(raw.get("pane_height")), + current_command=raw.get("pane_current_command"), + current_path=raw.get("pane_current_path"), + title=raw.get("pane_title"), + pid=_as_int(raw.get("pane_pid")), + fields=dict(raw), + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a plain dict (raw fields only; typed core re-derives).""" + return {"fields": dict(self.fields)} + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> PaneSnapshot: + """Reconstruct from :meth:`to_dict` output.""" + return cls.from_format(data["fields"]) + + +@dataclass(frozen=True) +class ClientSnapshot: + """An immutable snapshot of one attached tmux client. + + A client is a view (a terminal attachment), not part of the ownership tree, + so it is a leaf snapshot. + + Examples + -------- + >>> client = ClientSnapshot.from_format({ + ... "client_name": "/dev/pts/3", "client_tty": "/dev/pts/3", + ... "client_session": "$0", "client_pid": "4242", + ... }) + >>> client.name, client.session, client.pid + ('/dev/pts/3', '$0', 4242) + """ + + name: str = "" + tty: str | None = None + session: str = "" + pid: int | None = None + width: int | None = None + height: int | None = None + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> ClientSnapshot: + """Build a client snapshot from a raw tmux format mapping.""" + return cls( + name=raw.get("client_name", ""), + tty=raw.get("client_tty"), + session=raw.get("client_session", ""), + pid=_as_int(raw.get("client_pid")), + width=_as_int(raw.get("client_width")), + height=_as_int(raw.get("client_height")), + fields=dict(raw), + ) + + +@dataclass(frozen=True) +class WindowSnapshot: + """An immutable snapshot of one tmux window and its panes. + + Examples + -------- + >>> win = WindowSnapshot.from_format({ + ... "window_id": "@1", "window_index": "0", "window_name": "main", + ... "session_id": "$0", "window_active": "1", + ... }) + >>> win.window_id, win.window_index, win.name, win.active + ('@1', 0, 'main', True) + >>> win.panes + () + """ + + window_id: str = "" + window_index: int | None = None + name: str | None = None + session_id: str = "" + active: bool = False + layout: str | None = None + panes: tuple[PaneSnapshot, ...] = () + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> WindowSnapshot: + """Build a window snapshot from a raw tmux format mapping.""" + return cls( + window_id=raw.get("window_id", ""), + window_index=_as_int(raw.get("window_index")), + name=raw.get("window_name"), + session_id=raw.get("session_id", ""), + active=_as_bool(raw.get("window_active")), + layout=raw.get("window_layout"), + fields=dict(raw), + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a plain dict, including child panes.""" + return { + "fields": dict(self.fields), + "panes": [pane.to_dict() for pane in self.panes], + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> WindowSnapshot: + """Reconstruct from :meth:`to_dict` output.""" + return dataclasses.replace( + cls.from_format(data["fields"]), + panes=tuple(PaneSnapshot.from_dict(p) for p in data.get("panes", [])), + ) + + +@dataclass(frozen=True) +class SessionSnapshot: + """An immutable snapshot of one tmux session and its windows.""" + + session_id: str = "" + name: str | None = None + attached: bool = False + windows: tuple[WindowSnapshot, ...] = () + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> SessionSnapshot: + """Build a session snapshot from a raw tmux format mapping.""" + return cls( + session_id=raw.get("session_id", ""), + name=raw.get("session_name"), + attached=_as_bool(raw.get("session_attached")), + fields=dict(raw), + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a plain dict, including child windows.""" + return { + "fields": dict(self.fields), + "windows": [window.to_dict() for window in self.windows], + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> SessionSnapshot: + """Reconstruct from :meth:`to_dict` output.""" + return dataclasses.replace( + cls.from_format(data["fields"]), + windows=tuple(WindowSnapshot.from_dict(w) for w in data.get("windows", [])), + ) + + +@dataclass(frozen=True) +class ServerSnapshot: + """An immutable snapshot of a tmux server's session/window/pane tree. + + Examples + -------- + Build the whole graph from a flat ``list-panes -a -F`` style row set: + + >>> rows = [ + ... {"session_id": "$0", "session_name": "work", "window_id": "@1", + ... "window_index": "0", "window_name": "main", "pane_id": "%1", + ... "pane_index": "0", "pane_active": "1"}, + ... {"session_id": "$0", "session_name": "work", "window_id": "@1", + ... "window_index": "0", "window_name": "main", "pane_id": "%2", + ... "pane_index": "1", "pane_active": "0"}, + ... ] + >>> server = ServerSnapshot.from_pane_rows(rows, socket_name="default") + >>> [s.name for s in server.sessions] + ['work'] + >>> [p.pane_id for p in server.sessions[0].windows[0].panes] + ['%1', '%2'] + """ + + socket_name: str | None = None + socket_path: str | None = None + sessions: tuple[SessionSnapshot, ...] = () + + @classmethod + def from_pane_rows( + cls, + rows: Iterable[Mapping[str, str]], + *, + socket_name: str | None = None, + socket_path: str | None = None, + ) -> ServerSnapshot: + """Group flat per-pane rows into a session/window/pane tree. + + Each row is one pane's format mapping carrying its ``session_*`` and + ``window_*`` fields too (as ``tmux list-panes -a -F`` yields). The first + row seen for a session/window supplies that level's fields; insertion + order is preserved. + """ + session_order: list[str] = [] + session_fields: dict[str, Mapping[str, str]] = {} + window_order: dict[str, list[str]] = {} + window_fields: dict[str, Mapping[str, str]] = {} + window_panes: dict[str, list[PaneSnapshot]] = {} + + for row in rows: + session_id = row.get("session_id", "") + window_id = row.get("window_id", "") + if session_id not in session_fields: + session_fields[session_id] = row + session_order.append(session_id) + window_order[session_id] = [] + if window_id not in window_fields: + window_fields[window_id] = row + window_order[session_id].append(window_id) + window_panes[window_id] = [] + window_panes[window_id].append(PaneSnapshot.from_format(row)) + + sessions = tuple( + dataclasses.replace( + SessionSnapshot.from_format(session_fields[session_id]), + windows=tuple( + dataclasses.replace( + WindowSnapshot.from_format(window_fields[window_id]), + panes=tuple(window_panes[window_id]), + ) + for window_id in window_order[session_id] + ), + ) + for session_id in session_order + ) + return cls( + socket_name=socket_name, + socket_path=socket_path, + sessions=sessions, + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize the whole tree to plain data.""" + return { + "socket_name": self.socket_name, + "socket_path": self.socket_path, + "sessions": [session.to_dict() for session in self.sessions], + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> ServerSnapshot: + """Reconstruct the whole tree from :meth:`to_dict` output.""" + return cls( + socket_name=data.get("socket_name"), + socket_path=data.get("socket_path"), + sessions=tuple( + SessionSnapshot.from_dict(s) for s in data.get("sessions", []) + ), + ) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py new file mode 100644 index 000000000..e0d1a3e37 --- /dev/null +++ b/src/libtmux/experimental/ops/__init__.py @@ -0,0 +1,261 @@ +"""Inert, typed tmux operation values. + +This package is the pure source of truth for tmux commands: each +:class:`~.operation.Operation` renders a tmux argv, carries its result type and +metadata, and adapts raw output into a typed :class:`~.results.Result` -- all +without a live tmux server. Engines (:mod:`libtmux.experimental.engines`) +execute operations; :func:`run` / :func:`arun` bridge the two. + +Everything here is experimental and not covered by the versioning policy. + +Examples +-------- +>>> from libtmux.experimental.ops import SplitWindow, run +>>> from libtmux.experimental.ops._types import PaneId +>>> from libtmux.experimental.engines import CommandResult +>>> SplitWindow(target=PaneId("%1"), horizontal=True).render() +('split-window', '-t', '%1', '-h', '-P', '-F', '#{pane_id}') +""" + +from __future__ import annotations + +from libtmux.experimental.ops._chain import OpChain +from libtmux.experimental.ops._ops import ( + BreakPane, + CapturePane, + ClearHistory, + DeleteBuffer, + DetachClient, + DisplayMessage, + HasSession, + JoinPane, + KillPane, + KillServer, + KillSession, + KillWindow, + LastPane, + LastWindow, + LinkWindow, + ListClients, + ListPanes, + ListSessions, + ListWindows, + LoadBuffer, + MovePane, + MoveWindow, + NewSession, + NewWindow, + NextWindow, + PasteBuffer, + PipePane, + PreviousWindow, + RefreshClient, + RenameSession, + RenameWindow, + ResizePane, + ResizeWindow, + RespawnPane, + RespawnWindow, + RotateWindow, + RunShell, + SaveBuffer, + SelectLayout, + SelectPane, + SelectWindow, + SendKeys, + SetBuffer, + SetEnvironment, + SetHook, + SetOption, + SetWindowOption, + ShowBuffer, + ShowOptions, + SourceFile, + SplitWindow, + StartServer, + SuspendClient, + SwapPane, + SwapWindow, + SwitchClient, + UnlinkWindow, +) +from libtmux.experimental.ops._types import ( + ClientName, + Effects, + IndexRef, + NameRef, + PaneId, + Safety, + Scope, + SessionId, + SlotRef, + Special, + Status, + Target, + WindowId, + render_target, +) +from libtmux.experimental.ops.catalog import CatalogEntry, catalog +from libtmux.experimental.ops.exc import ( + DuplicateOperation, + OperationError, + TmuxCommandError, + UnknownOperation, + VersionUnsupported, +) +from libtmux.experimental.ops.execute import arun, run +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.plan import LazyPlan, PlanResult +from libtmux.experimental.ops.planner import ( + FoldingPlanner, + MarkedPlanner, + Planner, + PlanStep, + SequentialPlanner, +) +from libtmux.experimental.ops.registry import ( + OperationRegistry, + OpSpec, + register, + registry, +) +from libtmux.experimental.ops.results import ( + AckResult, + CapturePaneResult, + CreateResult, + DisplayMessageResult, + HasSessionResult, + ListClientsResult, + ListPanesResult, + ListSessionsResult, + ListWindowsResult, + Result, + ShowBufferResult, + ShowOptionsResult, + SplitWindowResult, + status_for, +) +from libtmux.experimental.ops.serialize import ( + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + target_from_dict, + target_to_dict, +) + +__all__ = ( + "AckResult", + "BreakPane", + "CapturePane", + "CapturePaneResult", + "CatalogEntry", + "ClearHistory", + "ClientName", + "CreateResult", + "DeleteBuffer", + "DetachClient", + "DisplayMessage", + "DisplayMessageResult", + "DuplicateOperation", + "Effects", + "FoldingPlanner", + "HasSession", + "HasSessionResult", + "IndexRef", + "JoinPane", + "KillPane", + "KillServer", + "KillSession", + "KillWindow", + "LastPane", + "LastWindow", + "LazyPlan", + "LinkWindow", + "ListClients", + "ListClientsResult", + "ListPanes", + "ListPanesResult", + "ListSessions", + "ListSessionsResult", + "ListWindows", + "ListWindowsResult", + "LoadBuffer", + "MarkedPlanner", + "MovePane", + "MoveWindow", + "NameRef", + "NewSession", + "NewWindow", + "NextWindow", + "OpChain", + "OpSpec", + "Operation", + "OperationError", + "OperationRegistry", + "PaneId", + "PasteBuffer", + "PipePane", + "PlanResult", + "PlanStep", + "Planner", + "PreviousWindow", + "RefreshClient", + "RenameSession", + "RenameWindow", + "ResizePane", + "ResizeWindow", + "RespawnPane", + "RespawnWindow", + "Result", + "RotateWindow", + "RunShell", + "Safety", + "SaveBuffer", + "Scope", + "SelectLayout", + "SelectPane", + "SelectWindow", + "SendKeys", + "SequentialPlanner", + "SessionId", + "SetBuffer", + "SetEnvironment", + "SetHook", + "SetOption", + "SetWindowOption", + "ShowBuffer", + "ShowBufferResult", + "ShowOptions", + "ShowOptionsResult", + "SlotRef", + "SourceFile", + "Special", + "SplitWindow", + "SplitWindowResult", + "StartServer", + "Status", + "SuspendClient", + "SwapPane", + "SwapWindow", + "SwitchClient", + "Target", + "TmuxCommandError", + "UnknownOperation", + "UnlinkWindow", + "VersionUnsupported", + "WindowId", + "arun", + "catalog", + "operation_from_dict", + "operation_to_dict", + "register", + "registry", + "render_target", + "result_from_dict", + "result_to_dict", + "run", + "status_for", + "target_from_dict", + "target_to_dict", +) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py new file mode 100644 index 000000000..2cb254109 --- /dev/null +++ b/src/libtmux/experimental/ops/_chain.py @@ -0,0 +1,207 @@ +r"""Chaining and ``;``-folding for lazy plans. + +A run of *chainable* operations can render to a single ``tmux a \; b`` invocation +and dispatch once, instead of one process fork / control-mode command per +operation. This ports the chainable-commands prototype's fold onto the typed-op +model: only operations whose ``chainable`` class var is ``True`` (no captured +output, no created object) fold; the rest dispatch alone. + +tmux runs a ``;`` sequence up to the first error and drops the remainder +(``cmd-queue.c`` ``cmdq_remove_group``), returning one merged stdout/exit with no +per-command boundary. :func:`attribute` recovers a typed result per operation: +on success every member is ``complete``; on failure the first member is +``failed`` and the rest are ``skipped`` (the status the spine reserves for +exactly this case). +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import PaneId, Special +from libtmux.experimental.ops.exc import OperationError + +if t.TYPE_CHECKING: + from collections.abc import Iterator, Sequence + + from libtmux.experimental.engines.base import CommandResult + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + + +def ensure_chainable(op: Operation[t.Any]) -> None: + """Raise if *op* cannot be folded into a ``;`` chain (fail closed).""" + if not op.chainable: + msg = ( + f"operation {op.kind!r} is not chainable; it produces output or " + f"creates an object and must dispatch on its own" + ) + raise OperationError(msg) + + +def _escape_arg(token: str) -> str: + r"""Escape a trailing ``;`` so tmux does not read the arg as a separator.""" + if token.endswith(";"): + return token[:-1] + "\\;" + return token + + +def render_chain( + ops: Sequence[Operation[t.Any]], + version: str | None = None, +) -> tuple[str, ...]: + r"""Render chainable ops to one argv with standalone ``;`` separators. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys, RenameWindow + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> render_chain([ + ... SendKeys(target=PaneId("%1"), keys="vim", enter=True), + ... RenameWindow(target=WindowId("@1"), name="edit"), + ... ]) + ('send-keys', '-t', '%1', 'vim', 'Enter', ';', 'rename-window', '-t', '@1', 'edit') + """ + out: list[str] = [] + for index, op in enumerate(ops): + if index: + out.append(";") + out.extend(_escape_arg(token) for token in op.render(version=version)) + return tuple(out) + + +def attribute( + ops: Sequence[Operation[t.Any]], + merged: CommandResult, + version: str | None = None, +) -> list[Result]: + """Split one merged ``;``-chain result into a typed result per operation.""" + if merged.returncode == 0 and not merged.stderr: + return [op.result_with_status("complete", version=version) for op in ops] + first, *rest = ops + results: list[Result] = [ + first.result_with_status( + "failed", + version=version, + returncode=merged.returncode, + stdout=tuple(merged.stdout), + stderr=tuple(merged.stderr), + ), + ] + results.extend(op.result_with_status("skipped", version=version) for op in rest) + return results + + +def render_marked( + create: Operation[t.Any], + decorates: Sequence[Operation[t.Any]], + version: str | None = None, +) -> tuple[str, ...]: + r"""Render a pane creation + its decorates as one ``{marked}`` invocation. + + Emits `` ; select-pane -m ; + ... ; select-pane -M``: the new pane is marked, every decorate addresses it + through tmux's ``{marked}`` register, and the mark is cleared at the end. + """ + parts: list[tuple[str, ...]] = [ + create.render(version=version), + ("select-pane", "-m"), + ] + parts.extend( + dataclasses.replace(op, target=Special("{marked}")).render(version=version) + for op in decorates + ) + parts.append(("select-pane", "-M")) + out: list[str] = [] + for index, part in enumerate(parts): + if index: + out.append(";") + out.extend(_escape_arg(token) for token in part) + return tuple(out) + + +def attribute_marked( + create: Operation[t.Any], + decorates: Sequence[Operation[t.Any]], + merged: CommandResult, + version: str | None = None, +) -> tuple[Result, list[Result], str | None]: + """Split a ``{marked}`` dispatch result into the create's + decorates' results.""" + new_id = (merged.stdout[0].strip() if merged.stdout else "") or None + # Attribute over the {marked}-retargeted decorates -- their original SlotRef + # target is unresolved and cannot render. + marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] + if new_id is None: + if merged.returncode == 0 and not merged.stderr: + # A non-capturing creator (capture=False) succeeded but emitted no + # id; every command in the fold ran. + create_result = create.build_result(returncode=0, version=version) + decorated = [ + op.result_with_status("complete", version=version) for op in marked + ] + return create_result, decorated, None + # The create step failed: tmux stopped, so no decorate ran -- skip them + # all rather than blaming the first. + create_result = create.build_result( + returncode=merged.returncode or 1, + stderr=tuple(merged.stderr), + version=version, + ) + decorated = [op.result_with_status("skipped", version=version) for op in marked] + return create_result, decorated, None + create_result = create.build_result(returncode=0, stdout=(new_id,), version=version) + # Attribute over decorates retargeted to the concrete new pane (not + # ``{marked}``) so each result's operation serializes and replays to the real + # pane; drop the create's captured id from stdout so a failed decorate is not + # credited with it. + resolved = [dataclasses.replace(op, target=PaneId(new_id)) for op in decorates] + decorated = attribute(resolved, dataclasses.replace(merged, stdout=()), version) + return create_result, decorated, new_id + + +@dataclass(frozen=True) +class OpChain: + """An ordered group of operations composed with :meth:`~.Operation.then`. + + A power-user, inspectable handle for explicit chaining. Add it to a + :class:`~.plan.LazyPlan` with :meth:`~.plan.LazyPlan.add_chain`; a folding + planner (``execute(planner=FoldingPlanner())``) batches chainable runs anyway. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys, RenameWindow + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> chain = ( + ... SendKeys(target=PaneId("%1"), keys="q") + ... >> RenameWindow(target=WindowId("@1"), name="done") + ... ) + >>> [op.kind for op in chain] + ['send_keys', 'rename_window'] + """ + + ops: tuple[Operation[t.Any], ...] + + def then(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Append an operation or chain.""" + return OpChain((*self.ops, *_as_ops(other))) + + def __rshift__(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Append with ``>>``.""" + return self.then(other) + + def __iter__(self) -> Iterator[Operation[t.Any]]: + """Iterate the operations in order.""" + return iter(self.ops) + + def __len__(self) -> int: + """Return the number of operations in the chain.""" + return len(self.ops) + + +def _as_ops(other: Operation[t.Any] | OpChain) -> tuple[Operation[t.Any], ...]: + """Normalize an operation or chain to a tuple of operations.""" + if isinstance(other, OpChain): + return other.ops + return (other,) diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py new file mode 100644 index 000000000..17d76abab --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -0,0 +1,126 @@ +"""Concrete seed operations. + +Importing this package registers each operation in the default registry +(:data:`libtmux.experimental.ops.registry.registry`) as a side effect of the +``@register`` decorator on each class. +""" + +from __future__ import annotations + +from libtmux.experimental.ops._ops.break_pane import BreakPane +from libtmux.experimental.ops._ops.capture_pane import CapturePane +from libtmux.experimental.ops._ops.clear_history import ClearHistory +from libtmux.experimental.ops._ops.delete_buffer import DeleteBuffer +from libtmux.experimental.ops._ops.detach_client import DetachClient +from libtmux.experimental.ops._ops.display_message import DisplayMessage +from libtmux.experimental.ops._ops.has_session import HasSession +from libtmux.experimental.ops._ops.join_pane import JoinPane +from libtmux.experimental.ops._ops.kill_pane import KillPane +from libtmux.experimental.ops._ops.kill_server import KillServer +from libtmux.experimental.ops._ops.kill_session import KillSession +from libtmux.experimental.ops._ops.kill_window import KillWindow +from libtmux.experimental.ops._ops.last_pane import LastPane +from libtmux.experimental.ops._ops.last_window import LastWindow +from libtmux.experimental.ops._ops.link_window import LinkWindow +from libtmux.experimental.ops._ops.list_clients import ListClients +from libtmux.experimental.ops._ops.list_panes import ListPanes +from libtmux.experimental.ops._ops.list_sessions import ListSessions +from libtmux.experimental.ops._ops.list_windows import ListWindows +from libtmux.experimental.ops._ops.load_buffer import LoadBuffer +from libtmux.experimental.ops._ops.move_pane import MovePane +from libtmux.experimental.ops._ops.move_window import MoveWindow +from libtmux.experimental.ops._ops.new_session import NewSession +from libtmux.experimental.ops._ops.new_window import NewWindow +from libtmux.experimental.ops._ops.next_window import NextWindow +from libtmux.experimental.ops._ops.paste_buffer import PasteBuffer +from libtmux.experimental.ops._ops.pipe_pane import PipePane +from libtmux.experimental.ops._ops.previous_window import PreviousWindow +from libtmux.experimental.ops._ops.refresh_client import RefreshClient +from libtmux.experimental.ops._ops.rename_session import RenameSession +from libtmux.experimental.ops._ops.rename_window import RenameWindow +from libtmux.experimental.ops._ops.resize_pane import ResizePane +from libtmux.experimental.ops._ops.resize_window import ResizeWindow +from libtmux.experimental.ops._ops.respawn_pane import RespawnPane +from libtmux.experimental.ops._ops.respawn_window import RespawnWindow +from libtmux.experimental.ops._ops.rotate_window import RotateWindow +from libtmux.experimental.ops._ops.run_shell import RunShell +from libtmux.experimental.ops._ops.save_buffer import SaveBuffer +from libtmux.experimental.ops._ops.select_layout import SelectLayout +from libtmux.experimental.ops._ops.select_pane import SelectPane +from libtmux.experimental.ops._ops.select_window import SelectWindow +from libtmux.experimental.ops._ops.send_keys import SendKeys +from libtmux.experimental.ops._ops.set_buffer import SetBuffer +from libtmux.experimental.ops._ops.set_environment import SetEnvironment +from libtmux.experimental.ops._ops.set_hook import SetHook +from libtmux.experimental.ops._ops.set_option import SetOption +from libtmux.experimental.ops._ops.set_window_option import SetWindowOption +from libtmux.experimental.ops._ops.show_buffer import ShowBuffer +from libtmux.experimental.ops._ops.show_options import ShowOptions +from libtmux.experimental.ops._ops.source_file import SourceFile +from libtmux.experimental.ops._ops.split_window import SplitWindow +from libtmux.experimental.ops._ops.start_server import StartServer +from libtmux.experimental.ops._ops.suspend_client import SuspendClient +from libtmux.experimental.ops._ops.swap_pane import SwapPane +from libtmux.experimental.ops._ops.swap_window import SwapWindow +from libtmux.experimental.ops._ops.switch_client import SwitchClient +from libtmux.experimental.ops._ops.unlink_window import UnlinkWindow + +__all__ = ( + "BreakPane", + "CapturePane", + "ClearHistory", + "DeleteBuffer", + "DetachClient", + "DisplayMessage", + "HasSession", + "JoinPane", + "KillPane", + "KillServer", + "KillSession", + "KillWindow", + "LastPane", + "LastWindow", + "LinkWindow", + "ListClients", + "ListPanes", + "ListSessions", + "ListWindows", + "LoadBuffer", + "MovePane", + "MoveWindow", + "NewSession", + "NewWindow", + "NextWindow", + "PasteBuffer", + "PipePane", + "PreviousWindow", + "RefreshClient", + "RenameSession", + "RenameWindow", + "ResizePane", + "ResizeWindow", + "RespawnPane", + "RespawnWindow", + "RotateWindow", + "RunShell", + "SaveBuffer", + "SelectLayout", + "SelectPane", + "SelectWindow", + "SendKeys", + "SetBuffer", + "SetEnvironment", + "SetHook", + "SetOption", + "SetWindowOption", + "ShowBuffer", + "ShowOptions", + "SourceFile", + "SplitWindow", + "StartServer", + "SuspendClient", + "SwapPane", + "SwapWindow", + "SwitchClient", + "UnlinkWindow", +) diff --git a/src/libtmux/experimental/ops/_ops/break_pane.py b/src/libtmux/experimental/ops/_ops/break_pane.py new file mode 100644 index 000000000..1034def13 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/break_pane.py @@ -0,0 +1,89 @@ +"""The ``break-pane`` operation (creates a window, captures its id).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CreateResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class BreakPane(Operation[CreateResult]): + """Break a pane out into a new window (``break-pane``). + + The pane to break is the ``-s`` source (``src_target``); there is no ``-t``. + By default it appends ``-P -F '#{window_id}'`` so the new window's id is + captured into :attr:`~.results.CreateResult.new_id`. + + Parameters + ---------- + detach : bool + Do not switch to the new window (``-d``). + name : str or None + Name for the new window (``-n``). + capture : bool + Append ``-P -F '#{window_id}'`` to capture the new window id. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> BreakPane(src_target=PaneId("%2"), name="logs").render() + ('break-pane', '-d', '-n', 'logs', '-P', '-F', '#{window_id}', '-s', '%2') + >>> BreakPane(src_target=PaneId("%2")).build_result( + ... returncode=0, stdout=("@7",) + ... ).new_id + '@7' + """ + + kind = "break_pane" + command = "break-pane" + scope = "window" + result_cls = CreateResult + safety = "mutating" + chainable = False + effects = Effects(creates="window") + + detach: bool = True + name: str | None = None + capture: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the break flags, capture template, and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.name is not None: + out.extend(("-n", self.name)) + if self.capture: + out.extend(("-P", "-F", "#{window_id}")) + out.extend(self.src_args()) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> CreateResult: + """Parse the captured new-window id.""" + new_id = stdout[0].strip() if status == "complete" and stdout else None + return CreateResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_id=new_id, + ) diff --git a/src/libtmux/experimental/ops/_ops/capture_pane.py b/src/libtmux/experimental/ops/_ops/capture_pane.py new file mode 100644 index 000000000..684e3c6f6 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/capture_pane.py @@ -0,0 +1,112 @@ +"""The ``capture-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CapturePaneResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class CapturePane(Operation[CapturePaneResult]): + """Capture a pane's contents (a read-only operation). + + Parameters + ---------- + start, end : int or None + Start/end line for the capture (``-S`` / ``-E``). + escape_sequences : bool + Include escape sequences (``-e``). + join_wrapped : bool + Join wrapped lines (``-J``). + trim_trailing : bool + Trim trailing whitespace (``-T``; tmux 3.4+, dropped on older tmux). + mode_screen : bool + Capture the visible screen in copy mode (``-M``; tmux 3.6+). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> CapturePane(target=PaneId("%1")).render() + ('capture-pane', '-t', '%1', '-p') + + Version-gated flags are dropped on older tmux: + + >>> op = CapturePane(target=PaneId("%1"), trim_trailing=True) + >>> op.render(version="3.3") + ('capture-pane', '-t', '%1', '-p') + >>> op.render(version="3.4") + ('capture-pane', '-t', '%1', '-p', '-T') + + Captured stdout is exposed as typed lines: + + >>> result = op.build_result(returncode=0, stdout=("foo", "bar")) + >>> result.lines + ('foo', 'bar') + """ + + kind = "capture_pane" + command = "capture-pane" + scope = "pane" + result_cls = CapturePaneResult + safety = "readonly" + chainable = False # produces stdout that must not be merged into a ; chain + effects = Effects(read_only=True, reads_output=True, idempotent=True) + flag_version_map: t.ClassVar[Mapping[str, str]] = { + "trim_trailing": "3.4", + "mode_screen": "3.6", + } + + start: int | None = None + end: int | None = None + escape_sequences: bool = False + join_wrapped: bool = False + trim_trailing: bool = False + mode_screen: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``capture-pane`` flags (``-p`` prints to stdout).""" + out: list[str] = ["-p"] + if self.start is not None: + out.extend(("-S", str(self.start))) + if self.end is not None: + out.extend(("-E", str(self.end))) + if self.escape_sequences: + out.append("-e") + if self.join_wrapped: + out.append("-J") + if self.trim_trailing and self.flag_available("trim_trailing", version): + out.append("-T") + if self.mode_screen and self.flag_available("mode_screen", version): + out.append("-M") + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> CapturePaneResult: + """Expose captured stdout as typed :attr:`~.CapturePaneResult.lines`.""" + return CapturePaneResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + lines=stdout, + ) diff --git a/src/libtmux/experimental/ops/_ops/clear_history.py b/src/libtmux/experimental/ops/_ops/clear_history.py new file mode 100644 index 000000000..2be1b405d --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/clear_history.py @@ -0,0 +1,30 @@ +"""The ``clear-history`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class ClearHistory(Operation[AckResult]): + """Clear a pane's scrollback history (``clear-history``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> ClearHistory(target=PaneId("%1")).render() + ('clear-history', '-t', '%1') + """ + + kind = "clear_history" + command = "clear-history" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/delete_buffer.py b/src/libtmux/experimental/ops/_ops/delete_buffer.py new file mode 100644 index 000000000..03ef19784 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/delete_buffer.py @@ -0,0 +1,44 @@ +"""The ``delete-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class DeleteBuffer(Operation[AckResult]): + """Delete a paste buffer (``delete-buffer``). + + Parameters + ---------- + buffer_name : str or None + The buffer to delete (``-b``); the most recent when omitted. + + Examples + -------- + >>> DeleteBuffer(buffer_name="b0").render() + ('delete-buffer', '-b', 'b0') + >>> DeleteBuffer().render() + ('delete-buffer',) + """ + + kind = "delete_buffer" + command = "delete-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + buffer_name: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-b`` buffer name.""" + if self.buffer_name is not None: + return ("-b", self.buffer_name) + return () diff --git a/src/libtmux/experimental/ops/_ops/detach_client.py b/src/libtmux/experimental/ops/_ops/detach_client.py new file mode 100644 index 000000000..d12cf0897 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/detach_client.py @@ -0,0 +1,36 @@ +"""The ``detach-client`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class DetachClient(Operation[AckResult]): + """Detach a client. Produces no output (:class:`AckResult`). + + ``target`` is the client (a :class:`~.._types.ClientName`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import ClientName + >>> DetachClient(target=ClientName("/dev/pts/3")).render() + ('detach-client', '-t', '/dev/pts/3') + """ + + kind = "detach_client" + command = "detach-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target client.""" + return () diff --git a/src/libtmux/experimental/ops/_ops/display_message.py b/src/libtmux/experimental/ops/_ops/display_message.py new file mode 100644 index 000000000..38ec8052c --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/display_message.py @@ -0,0 +1,65 @@ +"""The ``display-message -p`` operation -- a typed format query.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import DisplayMessageResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class DisplayMessage(Operation[DisplayMessageResult]): + """Evaluate a tmux format and print it (``display-message -p``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> DisplayMessage(target=PaneId("%1"), message="#{pane_width}").render() + ('display-message', '-t', '%1', '-p', '#{pane_width}') + >>> DisplayMessage(message="#{pane_id}").build_result( + ... returncode=0, stdout=("%1",) + ... ).text + '%1' + """ + + kind = "display_message" + command = "display-message" + scope = "pane" + result_cls = DisplayMessageResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, reads_output=True, idempotent=True) + + message: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-p ``.""" + return ("-p", self.message) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> DisplayMessageResult: + """Expose the printed output as :attr:`~.DisplayMessageResult.text`.""" + return DisplayMessageResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + text="\n".join(stdout), + ) diff --git a/src/libtmux/experimental/ops/_ops/has_session.py b/src/libtmux/experimental/ops/_ops/has_session.py new file mode 100644 index 000000000..2f1fa9c8b --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/has_session.py @@ -0,0 +1,72 @@ +"""The ``has-session`` operation -- a typed existence query.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import HasSessionResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class HasSession(Operation[HasSessionResult]): + """Check whether a session exists (``has-session``). + + ``target`` is the session. A missing session is a valid answer (rc 1), not + an error, so the result is always ``complete`` and carries the answer in + :attr:`~.HasSessionResult.exists`. + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> HasSession(target=SessionId("$0")).render() + ('has-session', '-t', '$0') + >>> HasSession(target=SessionId("$0")).build_result(returncode=1).exists + False + """ + + kind = "has_session" + command = "has-session" + scope = "session" + result_cls = HasSessionResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target.""" + return () + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> HasSessionResult: + """Map the exit code to existence; the query itself always completes. + + ``has-session`` writes its "can't find session" message to stderr; surface + it in stdout here (rather than in each engine) so the result is consistent + across engines. + """ + if stderr and not stdout: + stdout = (stderr[0],) + return HasSessionResult( + operation=self, + argv=argv, + status="complete", + returncode=returncode, + stdout=stdout, + stderr=stderr, + exists=returncode == 0, + ) diff --git a/src/libtmux/experimental/ops/_ops/join_pane.py b/src/libtmux/experimental/ops/_ops/join_pane.py new file mode 100644 index 000000000..7924517f5 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/join_pane.py @@ -0,0 +1,66 @@ +"""The ``join-pane`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class JoinPane(Operation[AckResult]): + """Join a source pane into a destination window/pane (``join-pane``). + + ``target`` is the destination (``-t``); ``src_target`` is the pane to move + (``-s``). The inverse of :class:`BreakPane`. + + Parameters + ---------- + horizontal : bool + Split the destination left/right (``-h``) instead of top/bottom (``-v``). + detach : bool + Do not switch to the destination window (``-d``). + full_size : bool + Span the full window width/height (``-f``). + size : int or None + Size of the joined pane (``-l``). + before : bool + Place the pane before the destination (``-b``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> JoinPane(target=WindowId("@1"), src_target=PaneId("%2")).render() + ('join-pane', '-t', '@1', '-v', '-d', '-s', '%2') + """ + + kind = "join_pane" + command = "join-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + horizontal: bool = False + detach: bool = True + full_size: bool = False + size: int | None = None + before: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the join flags and ``-s`` source.""" + out: list[str] = ["-h" if self.horizontal else "-v"] + if self.detach: + out.append("-d") + if self.full_size: + out.append("-f") + if self.size is not None: + out.append(f"-l{self.size}") + if self.before: + out.append("-b") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/kill_pane.py b/src/libtmux/experimental/ops/_ops/kill_pane.py new file mode 100644 index 000000000..7e835fc4f --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_pane.py @@ -0,0 +1,41 @@ +"""The ``kill-pane`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillPane(Operation[AckResult]): + """Kill a pane. Destructive; produces no output (:class:`AckResult`). + + Parameters + ---------- + others : bool + Kill all panes *except* the target (``-a``) instead of the target. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> KillPane(target=PaneId("%1")).render() + ('kill-pane', '-t', '%1') + """ + + kind = "kill_pane" + command = "kill-pane" + scope = "pane" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) + + others: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.others else () diff --git a/src/libtmux/experimental/ops/_ops/kill_server.py b/src/libtmux/experimental/ops/_ops/kill_server.py new file mode 100644 index 000000000..a5754d648 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_server.py @@ -0,0 +1,29 @@ +"""The ``kill-server`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillServer(Operation[AckResult]): + """Kill the tmux server and all its sessions (``kill-server``). + + Examples + -------- + >>> KillServer().render() + ('kill-server',) + """ + + kind = "kill_server" + command = "kill-server" + scope = "server" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) diff --git a/src/libtmux/experimental/ops/_ops/kill_session.py b/src/libtmux/experimental/ops/_ops/kill_session.py new file mode 100644 index 000000000..a141de693 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_session.py @@ -0,0 +1,34 @@ +"""The ``kill-session`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillSession(Operation[AckResult]): + """Kill a session. Destructive; produces no output (:class:`AckResult`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> KillSession(target=SessionId("$0")).render() + ('kill-session', '-t', '$0') + """ + + kind = "kill_session" + command = "kill-session" + scope = "session" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target.""" + return () diff --git a/src/libtmux/experimental/ops/_ops/kill_window.py b/src/libtmux/experimental/ops/_ops/kill_window.py new file mode 100644 index 000000000..f70a5f987 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_window.py @@ -0,0 +1,43 @@ +"""The ``kill-window`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillWindow(Operation[AckResult]): + """Kill a window. Destructive; produces no output (:class:`AckResult`). + + Parameters + ---------- + others : bool + Kill all windows *except* the target (``-a``) instead of the target. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> KillWindow(target=WindowId("@1")).render() + ('kill-window', '-t', '@1') + >>> KillWindow(target=WindowId("@1"), others=True).render() + ('kill-window', '-t', '@1', '-a') + """ + + kind = "kill_window" + command = "kill-window" + scope = "window" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) + + others: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.others else () diff --git a/src/libtmux/experimental/ops/_ops/last_pane.py b/src/libtmux/experimental/ops/_ops/last_pane.py new file mode 100644 index 000000000..21fadb606 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/last_pane.py @@ -0,0 +1,32 @@ +"""The ``last-pane`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LastPane(Operation[AckResult]): + """Select the previously active pane in a window (``last-pane``). + + ``target`` is the window whose last pane to select. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> LastPane(target=WindowId("@1")).render() + ('last-pane', '-t', '@1') + """ + + kind = "last_pane" + command = "last-pane" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/last_window.py b/src/libtmux/experimental/ops/_ops/last_window.py new file mode 100644 index 000000000..b62fa2732 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/last_window.py @@ -0,0 +1,32 @@ +"""The ``last-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LastWindow(Operation[AckResult]): + """Select the previously active window (``last-window``). + + ``target`` is the session. + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> LastWindow(target=SessionId("$0")).render() + ('last-window', '-t', '$0') + """ + + kind = "last_window" + command = "last-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/link_window.py b/src/libtmux/experimental/ops/_ops/link_window.py new file mode 100644 index 000000000..2eb640498 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/link_window.py @@ -0,0 +1,61 @@ +"""The ``link-window`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LinkWindow(Operation[AckResult]): + """Link a window into another session (``link-window``). + + ``target`` is the destination (``-t``); ``src_target`` is the window to + link (``-s``). + + Parameters + ---------- + detach : bool + Do not change the active window (``-d``). + before, after : bool + Insert before (``-b``) or after (``-a``) the destination index. + kill : bool + Replace (kill) any window already at the destination (``-k``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId, WindowId + >>> LinkWindow(target=SessionId("$0"), src_target=WindowId("@2")).render() + ('link-window', '-t', '$0', '-s', '@2') + """ + + kind = "link_window" + command = "link-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + before: bool = False + after: bool = False + kill: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the link flags and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.before: + out.append("-b") + if self.after: + out.append("-a") + if self.kill: + out.append("-k") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/list_clients.py b/src/libtmux/experimental/ops/_ops/list_clients.py new file mode 100644 index 000000000..dfc1023f1 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_clients.py @@ -0,0 +1,70 @@ +"""The ``list-clients`` operation -- typed client snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListClientsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListClients(Operation[ListClientsResult]): + """List attached clients and return typed snapshots. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> run(ListClients(), ConcreteEngine(), version="3.6a").clients + () + """ + + kind = "list_clients" + command = "list-clients" + scope = "server" + result_cls = ListClientsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the ``-F`` format template.""" + _fields, fmt = get_output_format( + "list-clients", version or DEFAULT_LIST_VERSION + ) + return ("-F", fmt) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListClientsResult: + """Parse each output row into a client format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple(parse_output(line, "list-clients", ver) for line in stdout if line) + return ListClientsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/list_panes.py b/src/libtmux/experimental/ops/_ops/list_panes.py new file mode 100644 index 000000000..702a4d740 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_panes.py @@ -0,0 +1,92 @@ +"""The ``list-panes`` operation -- a typed read returning snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListPanesResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListPanes(Operation[ListPanesResult]): + """List panes and return typed snapshots (a read operation). + + Renders the same ``-F`` template the ORM reader uses (via + :func:`libtmux.neo.get_output_format`) and parses each row into a + :class:`~libtmux.experimental.models.PaneSnapshot`; with ``all_panes`` the + result also exposes the full :class:`ServerSnapshot` tree. + + Parameters + ---------- + all_panes : bool + List panes across the whole server (``-a``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> op = ListPanes() + >>> op.render(version="3.6a")[:1] + ('list-panes',) + >>> "-a" in op.render(version="3.6a") + True + >>> result = run(op, ConcreteEngine(), version="3.6a") + >>> result.rows + () + >>> result.server.sessions + () + """ + + kind = "list_panes" + command = "list-panes" + scope = "server" + result_cls = ListPanesResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + all_panes: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-a`` (optional) and the ``-F`` format template.""" + _fields, fmt = get_output_format("list-panes", version or DEFAULT_LIST_VERSION) + out: list[str] = [] + if self.all_panes: + out.append("-a") + out.extend(("-F", fmt)) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListPanesResult: + """Parse each output row into a pane format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple(parse_output(line, "list-panes", ver) for line in stdout if line) + return ListPanesResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/list_sessions.py b/src/libtmux/experimental/ops/_ops/list_sessions.py new file mode 100644 index 000000000..c120b06f1 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_sessions.py @@ -0,0 +1,73 @@ +"""The ``list-sessions`` operation -- a typed read returning snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListSessionsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListSessions(Operation[ListSessionsResult]): + """List the server's sessions and return typed :class:`SessionSnapshot` rows. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> run(ListSessions(), ConcreteEngine(), version="3.6a").sessions + () + """ + + kind = "list_sessions" + command = "list-sessions" + scope = "server" + result_cls = ListSessionsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the ``-F`` format template (list-sessions is server-wide).""" + _fields, fmt = get_output_format( + "list-sessions", + version or DEFAULT_LIST_VERSION, + ) + return ("-F", fmt) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListSessionsResult: + """Parse each output row into a session format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple( + parse_output(line, "list-sessions", ver) for line in stdout if line + ) + return ListSessionsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/list_windows.py b/src/libtmux/experimental/ops/_ops/list_windows.py new file mode 100644 index 000000000..3126c58b3 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_windows.py @@ -0,0 +1,81 @@ +"""The ``list-windows`` operation -- a typed read returning snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListWindowsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListWindows(Operation[ListWindowsResult]): + """List windows and return typed :class:`WindowSnapshot` rows. + + Parameters + ---------- + all_windows : bool + List windows across the whole server (``-a``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> run(ListWindows(), ConcreteEngine(), version="3.6a").windows + () + """ + + kind = "list_windows" + command = "list-windows" + scope = "server" + result_cls = ListWindowsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + all_windows: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-a`` (optional) and the ``-F`` format template.""" + _fields, fmt = get_output_format( + "list-windows", version or DEFAULT_LIST_VERSION + ) + out: list[str] = [] + if self.all_windows: + out.append("-a") + out.extend(("-F", fmt)) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListWindowsResult: + """Parse each output row into a window format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple(parse_output(line, "list-windows", ver) for line in stdout if line) + return ListWindowsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/load_buffer.py b/src/libtmux/experimental/ops/_ops/load_buffer.py new file mode 100644 index 000000000..f868b89d1 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/load_buffer.py @@ -0,0 +1,49 @@ +"""The ``load-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LoadBuffer(Operation[AckResult]): + """Load a paste buffer from a file (``load-buffer``). + + Parameters + ---------- + path : str + The file to load (``-`` for stdin). + buffer_name : str or None + The buffer to load into (``-b``). + + Examples + -------- + >>> LoadBuffer(path="/tmp/x").render() + ('load-buffer', '/tmp/x') + >>> LoadBuffer(buffer_name="b0", path="/tmp/x").render() + ('load-buffer', '-b', 'b0', '/tmp/x') + """ + + kind = "load_buffer" + command = "load-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + path: str + buffer_name: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional buffer name and path.""" + out: list[str] = [] + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + out.append(self.path) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/move_pane.py b/src/libtmux/experimental/ops/_ops/move_pane.py new file mode 100644 index 000000000..75fe6feb4 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/move_pane.py @@ -0,0 +1,27 @@ +"""The ``move-pane`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._ops.join_pane import JoinPane +from libtmux.experimental.ops.registry import register + + +@register +@dataclass(frozen=True, kw_only=True) +class MovePane(JoinPane): + """Move a source pane into a destination window (``move-pane``). + + Identical in shape to :class:`JoinPane`; tmux exposes ``move-pane`` as the + same command under a different name. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> MovePane(target=WindowId("@1"), src_target=PaneId("%2")).render() + ('move-pane', '-t', '@1', '-v', '-d', '-s', '%2') + """ + + kind = "move_pane" + command = "move-pane" diff --git a/src/libtmux/experimental/ops/_ops/move_window.py b/src/libtmux/experimental/ops/_ops/move_window.py new file mode 100644 index 000000000..99092c6a0 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/move_window.py @@ -0,0 +1,66 @@ +"""The ``move-window`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class MoveWindow(Operation[AckResult]): + """Move a window to a new index/session (``move-window``). + + ``target`` is the destination (``-t``); ``src_target`` is the window to + move (``-s``). + + Parameters + ---------- + detach : bool + Do not change the active window (``-d``). + before, after : bool + Insert before (``-b``) or after (``-a``) the destination index. + kill : bool + Replace (kill) any window already at the destination (``-k``). + renumber : bool + Renumber windows to close gaps (``-r``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId, WindowId + >>> MoveWindow(target=SessionId("$0"), src_target=WindowId("@2")).render() + ('move-window', '-t', '$0', '-s', '@2') + """ + + kind = "move_window" + command = "move-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + before: bool = False + after: bool = False + kill: bool = False + renumber: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the move flags and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.before: + out.append("-b") + if self.after: + out.append("-a") + if self.kill: + out.append("-k") + if self.renumber: + out.append("-r") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/new_session.py b/src/libtmux/experimental/ops/_ops/new_session.py new file mode 100644 index 000000000..b92759c24 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/new_session.py @@ -0,0 +1,107 @@ +"""The ``new-session`` operation (creates a session, captures its id).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CreateResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class NewSession(Operation[CreateResult]): + """Create a detached session; capture the new session's id. + + Parameters + ---------- + capture_panes : bool + Also capture the new session's first window id and first pane id (into + :attr:`~.results.CreateResult.first_window_id` / + :attr:`~.results.CreateResult.first_pane_id`), so a plan can target them + via ``slot.window`` / ``slot.pane``. + + Examples + -------- + >>> NewSession(session_name="work").render() + ('new-session', '-d', '-s', 'work', '-P', '-F', '#{session_id}') + >>> NewSession(session_name="work", capture_panes=True).render()[-1] + '#{session_id} #{window_id} #{pane_id}' + >>> NewSession().build_result(returncode=0, stdout=("$2",)).new_id + '$2' + >>> r = NewSession(capture_panes=True).build_result( + ... returncode=0, stdout=("$2 @3 %4",) + ... ) + >>> (r.new_id, r.first_window_id, r.first_pane_id) + ('$2', '@3', '%4') + """ + + kind = "new_session" + command = "new-session" + scope = "server" + result_cls = CreateResult + safety = "mutating" + chainable = False + effects = Effects(creates="session") + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + session_name: str | None = None + start_directory: str | None = None + environment: Mapping[str, str] | None = None + width: int | None = None + height: int | None = None + capture: bool = True + capture_panes: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``new-session`` flags (always detached for headless use).""" + out: list[str] = ["-d"] + if self.session_name is not None: + out.extend(("-s", self.session_name)) + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.width is not None: + out.extend(("-x", str(self.width))) + if self.height is not None: + out.extend(("-y", str(self.height))) + if self.capture: + fmt = ( + "#{session_id} #{window_id} #{pane_id}" + if self.capture_panes + else "#{session_id}" + ) + out.extend(("-P", "-F", fmt)) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> CreateResult: + """Parse the captured session id (and first window/pane id if captured).""" + ids = stdout[0].split() if status == "complete" and stdout else [] + return CreateResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_id=ids[0] if ids else None, + first_window_id=ids[1] if len(ids) > 1 else None, + first_pane_id=ids[2] if len(ids) > 2 else None, + ) diff --git a/src/libtmux/experimental/ops/_ops/new_window.py b/src/libtmux/experimental/ops/_ops/new_window.py new file mode 100644 index 000000000..d6820c787 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/new_window.py @@ -0,0 +1,104 @@ +"""The ``new-window`` operation (creates a window, captures its id).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CreateResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class NewWindow(Operation[CreateResult]): + """Create a window in a session; capture the new window's id. + + ``target`` is the session the window is created in. + + Parameters + ---------- + capture_pane : bool + Also capture the new window's first pane id (into + :attr:`~.results.CreateResult.first_pane_id`), so a plan can target it + via ``slot.pane``. + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> NewWindow(target=SessionId("$0"), name="build").render() + ('new-window', '-t', '$0', '-d', '-n', 'build', '-P', '-F', '#{window_id}') + >>> NewWindow(target=SessionId("$0"), capture_pane=True).render()[-1] + '#{window_id} #{pane_id}' + >>> NewWindow(target=SessionId("$0")).build_result( + ... returncode=0, stdout=("@5",) + ... ).new_id + '@5' + >>> r = NewWindow(capture_pane=True).build_result(returncode=0, stdout=("@5 %6",)) + >>> (r.new_id, r.first_pane_id) + ('@5', '%6') + """ + + kind = "new_window" + command = "new-window" + scope = "session" + result_cls = CreateResult + safety = "mutating" + chainable = False + effects = Effects(creates="window") + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + name: str | None = None + start_directory: str | None = None + environment: Mapping[str, str] | None = None + detach: bool = True + capture: bool = True + capture_pane: bool = False + window_shell: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``new-window`` flags.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.name is not None: + out.extend(("-n", self.name)) + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.capture: + fmt = "#{window_id} #{pane_id}" if self.capture_pane else "#{window_id}" + out.extend(("-P", "-F", fmt)) + if self.window_shell is not None: + out.append(self.window_shell) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> CreateResult: + """Parse the captured window id (and first pane id if captured).""" + ids = stdout[0].split() if status == "complete" and stdout else [] + return CreateResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_id=ids[0] if ids else None, + first_pane_id=ids[1] if len(ids) > 1 else None, + ) diff --git a/src/libtmux/experimental/ops/_ops/next_window.py b/src/libtmux/experimental/ops/_ops/next_window.py new file mode 100644 index 000000000..0087aba16 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/next_window.py @@ -0,0 +1,41 @@ +"""The ``next-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class NextWindow(Operation[AckResult]): + """Select the next window in a session (``next-window``). + + Parameters + ---------- + alert : bool + Move to the next window with an alert (``-a``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> NextWindow(target=SessionId("$0")).render() + ('next-window', '-t', '$0') + """ + + kind = "next_window" + command = "next-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + alert: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.alert else () diff --git a/src/libtmux/experimental/ops/_ops/paste_buffer.py b/src/libtmux/experimental/ops/_ops/paste_buffer.py new file mode 100644 index 000000000..a4219058e --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/paste_buffer.py @@ -0,0 +1,69 @@ +"""The ``paste-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class PasteBuffer(Operation[AckResult]): + """Paste a buffer into a pane (``paste-buffer``). + + ``target`` is the destination pane. + + Parameters + ---------- + buffer_name : str or None + The buffer to paste (``-b``); the most recent when omitted. + delete : bool + Delete the buffer after pasting (``-d``). + bracket : bool + Use bracketed paste mode (``-p``). + no_replace : bool + Do no separator replacement: keep linefeeds (LF) instead of + converting them to the default carriage-return separator (``-r``). + separator : str or None + Separator inserted between lines (``-s``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> PasteBuffer(target=PaneId("%1")).render() + ('paste-buffer', '-t', '%1') + >>> PasteBuffer(target=PaneId("%1"), delete=True).render() + ('paste-buffer', '-t', '%1', '-d') + """ + + kind = "paste_buffer" + command = "paste-buffer" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(writes_input=True) + + buffer_name: str | None = None + delete: bool = False + bracket: bool = False + no_replace: bool = False + separator: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the paste flags and buffer name.""" + out: list[str] = [] + if self.delete: + out.append("-d") + if self.bracket: + out.append("-p") + if self.no_replace: + out.append("-r") + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + if self.separator is not None: + out.extend(("-s", self.separator)) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/pipe_pane.py b/src/libtmux/experimental/ops/_ops/pipe_pane.py new file mode 100644 index 000000000..1ed4c5223 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/pipe_pane.py @@ -0,0 +1,61 @@ +"""The ``pipe-pane`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class PipePane(Operation[AckResult]): + """Pipe a pane's output to a shell command (``pipe-pane``). + + Parameters + ---------- + command_line : str or None + Shell command to pipe to. Omit to stop an existing pipe. + stdin : bool + Connect the pane's input to the command (``-I``). + stdout : bool + Connect the pane's output to the command (``-O``). + toggle : bool + Only open the pipe if no pipe is already open on the pane (``-o``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> PipePane(target=PaneId("%1"), command_line="cat >>/tmp/log").render() + ('pipe-pane', '-t', '%1', 'cat >>/tmp/log') + >>> PipePane(target=PaneId("%1")).render() + ('pipe-pane', '-t', '%1') + """ + + kind = "pipe_pane" + command = "pipe-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(reads_output=True) + + command_line: str | None = None + stdin: bool = False + stdout: bool = False + toggle: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the pipe flags and command.""" + out: list[str] = [] + if self.stdin: + out.append("-I") + if self.stdout: + out.append("-O") + if self.toggle: + out.append("-o") + if self.command_line is not None: + out.append(self.command_line) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/previous_window.py b/src/libtmux/experimental/ops/_ops/previous_window.py new file mode 100644 index 000000000..25cf1e2d0 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/previous_window.py @@ -0,0 +1,41 @@ +"""The ``previous-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class PreviousWindow(Operation[AckResult]): + """Select the previous window in a session (``previous-window``). + + Parameters + ---------- + alert : bool + Move to the previous window with an alert (``-a``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> PreviousWindow(target=SessionId("$0")).render() + ('previous-window', '-t', '$0') + """ + + kind = "previous_window" + command = "previous-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + alert: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.alert else () diff --git a/src/libtmux/experimental/ops/_ops/refresh_client.py b/src/libtmux/experimental/ops/_ops/refresh_client.py new file mode 100644 index 000000000..624d81d15 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/refresh_client.py @@ -0,0 +1,34 @@ +"""The ``refresh-client`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RefreshClient(Operation[AckResult]): + """Refresh a client. Produces no output (:class:`AckResult`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import ClientName + >>> RefreshClient(target=ClientName("/dev/pts/3")).render() + ('refresh-client', '-t', '/dev/pts/3') + """ + + kind = "refresh_client" + command = "refresh-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target client.""" + return () diff --git a/src/libtmux/experimental/ops/_ops/rename_session.py b/src/libtmux/experimental/ops/_ops/rename_session.py new file mode 100644 index 000000000..937a878e6 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/rename_session.py @@ -0,0 +1,36 @@ +"""The ``rename-session`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RenameSession(Operation[AckResult]): + """Rename a session. Produces no output (:class:`AckResult`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> RenameSession(target=SessionId("$0"), name="work").render() + ('rename-session', '-t', '$0', 'work') + """ + + kind = "rename_session" + command = "rename-session" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + name: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the new session name.""" + return (self.name,) diff --git a/src/libtmux/experimental/ops/_ops/rename_window.py b/src/libtmux/experimental/ops/_ops/rename_window.py new file mode 100644 index 000000000..4cae1a96f --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/rename_window.py @@ -0,0 +1,41 @@ +"""The ``rename-window`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RenameWindow(Operation[AckResult]): + """Rename a window. Produces no output; returns an :class:`AckResult`. + + Parameters + ---------- + name : str + The new window name. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> RenameWindow(target=WindowId("@1"), name="build").render() + ('rename-window', '-t', '@1', 'build') + """ + + kind = "rename_window" + command = "rename-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + name: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the new name.""" + return (self.name,) diff --git a/src/libtmux/experimental/ops/_ops/resize_pane.py b/src/libtmux/experimental/ops/_ops/resize_pane.py new file mode 100644 index 000000000..72b6624b8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/resize_pane.py @@ -0,0 +1,65 @@ +"""The ``resize-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class ResizePane(Operation[AckResult]): + """Resize a pane, optionally zooming it. + + Parameters + ---------- + direction : {"L", "R", "U", "D"} or None + Resize toward a side (``-L``/``-R``/``-U``/``-D``). + adjustment : int or None + Cells to adjust by when *direction* is set. + width, height : int or None + Absolute width (``-x``) / height (``-y``) in cells. + zoom : bool + Toggle pane zoom (``-Z``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> ResizePane(target=PaneId("%1"), height=20).render() + ('resize-pane', '-t', '%1', '-y20') + >>> ResizePane(target=PaneId("%1"), direction="D", adjustment=5).render() + ('resize-pane', '-t', '%1', '-D', '5') + """ + + kind = "resize_pane" + command = "resize-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(mutates_layout=True) + + direction: t.Literal["L", "R", "U", "D"] | None = None + adjustment: int | None = None + width: int | None = None + height: int | None = None + zoom: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the resize flags.""" + out: list[str] = [] + if self.zoom: + out.append("-Z") + if self.direction is not None: + out.append(f"-{self.direction}") + if self.adjustment is not None: + out.append(str(self.adjustment)) + if self.width is not None: + out.append(f"-x{self.width}") + if self.height is not None: + out.append(f"-y{self.height}") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/resize_window.py b/src/libtmux/experimental/ops/_ops/resize_window.py new file mode 100644 index 000000000..952753340 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/resize_window.py @@ -0,0 +1,58 @@ +"""The ``resize-window`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class ResizeWindow(Operation[AckResult]): + """Resize a window (``resize-window``). + + Parameters + ---------- + direction : {"L", "R", "U", "D"} or None + Resize toward a side (``-L``/``-R``/``-U``/``-D``). + adjustment : int or None + Cells to adjust by when *direction* is set. + width, height : int or None + Absolute width (``-x``) / height (``-y``) in cells. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> ResizeWindow(target=WindowId("@1"), width=100).render() + ('resize-window', '-t', '@1', '-x100') + """ + + kind = "resize_window" + command = "resize-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(mutates_layout=True) + + direction: t.Literal["L", "R", "U", "D"] | None = None + adjustment: int | None = None + width: int | None = None + height: int | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the resize flags.""" + out: list[str] = [] + if self.direction is not None: + out.append(f"-{self.direction}") + if self.adjustment is not None: + out.append(str(self.adjustment)) + if self.width is not None: + out.append(f"-x{self.width}") + if self.height is not None: + out.append(f"-y{self.height}") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/respawn_pane.py b/src/libtmux/experimental/ops/_ops/respawn_pane.py new file mode 100644 index 000000000..4b09f50c5 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/respawn_pane.py @@ -0,0 +1,64 @@ +"""The ``respawn-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + +@register +@dataclass(frozen=True, kw_only=True) +class RespawnPane(Operation[AckResult]): + """Restart the command in a (usually dead) pane (``respawn-pane``). + + Parameters + ---------- + kill : bool + Kill the existing process first (``-k``). + start_directory : str or None + Working directory for the new process (``-c``). + environment : Mapping[str, str] or None + Environment variables (``-e``; tmux 3.0+). + shell : str or None + Command to run instead of the default shell. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> RespawnPane(target=PaneId("%1"), kill=True).render() + ('respawn-pane', '-t', '%1', '-k') + """ + + kind = "respawn_pane" + command = "respawn-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects() + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + kill: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + shell: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the respawn flags.""" + out: list[str] = [] + if self.kill: + out.append("-k") + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.shell is not None: + out.append(self.shell) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/respawn_window.py b/src/libtmux/experimental/ops/_ops/respawn_window.py new file mode 100644 index 000000000..93926cf68 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/respawn_window.py @@ -0,0 +1,64 @@ +"""The ``respawn-window`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + +@register +@dataclass(frozen=True, kw_only=True) +class RespawnWindow(Operation[AckResult]): + """Restart the command in a (usually dead) window (``respawn-window``). + + Parameters + ---------- + kill : bool + Kill the existing process first (``-k``). + start_directory : str or None + Working directory for the new process (``-c``). + environment : Mapping[str, str] or None + Environment variables (``-e``; tmux 3.0+). + shell : str or None + Command to run instead of the default shell. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> RespawnWindow(target=WindowId("@1"), kill=True).render() + ('respawn-window', '-t', '@1', '-k') + """ + + kind = "respawn_window" + command = "respawn-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + kill: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + shell: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the respawn flags.""" + out: list[str] = [] + if self.kill: + out.append("-k") + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.shell is not None: + out.append(self.shell) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/rotate_window.py b/src/libtmux/experimental/ops/_ops/rotate_window.py new file mode 100644 index 000000000..1ceff7b4c --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/rotate_window.py @@ -0,0 +1,54 @@ +"""The ``rotate-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RotateWindow(Operation[AckResult]): + """Rotate the panes in a window (``rotate-window``). + + Parameters + ---------- + up, down : bool + Rotate upward (``-U``) or downward (``-D``). + zoom : bool + Keep the window zoomed (``-Z``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> RotateWindow(target=WindowId("@1")).render() + ('rotate-window', '-t', '@1') + >>> RotateWindow(target=WindowId("@1"), up=True).render() + ('rotate-window', '-t', '@1', '-U') + """ + + kind = "rotate_window" + command = "rotate-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(mutates_layout=True) + + up: bool = False + down: bool = False + zoom: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the rotate flags.""" + out: list[str] = [] + if self.up: + out.append("-U") + if self.down: + out.append("-D") + if self.zoom: + out.append("-Z") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/run_shell.py b/src/libtmux/experimental/ops/_ops/run_shell.py new file mode 100644 index 000000000..9fab49ece --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/run_shell.py @@ -0,0 +1,53 @@ +"""The ``run-shell`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RunShell(Operation[AckResult]): + """Run a shell command via tmux (``run-shell``). + + Parameters + ---------- + command_line : str or None + The shell command to run. + background : bool + Run in the background (``-b``). + delay : int or None + Delay in seconds before running (``-d``). + + Examples + -------- + >>> RunShell(command_line="echo hi").render() + ('run-shell', 'echo hi') + """ + + kind = "run_shell" + command = "run-shell" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + command_line: str | None = None + background: bool = False + delay: int | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the run-shell flags and command.""" + out: list[str] = [] + if self.background: + out.append("-b") + if self.delay is not None: + out.extend(("-d", str(self.delay))) + if self.command_line is not None: + out.append(self.command_line) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/save_buffer.py b/src/libtmux/experimental/ops/_ops/save_buffer.py new file mode 100644 index 000000000..19176b8c0 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/save_buffer.py @@ -0,0 +1,54 @@ +"""The ``save-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SaveBuffer(Operation[AckResult]): + """Save a paste buffer to a file (``save-buffer``). + + Parameters + ---------- + path : str + The file to write (``-`` for stdout). + buffer_name : str or None + The buffer to save (``-b``). + append : bool + Append to the file instead of overwriting it (``-a``). + + Examples + -------- + >>> SaveBuffer(path="/tmp/x").render() + ('save-buffer', '/tmp/x') + >>> SaveBuffer(buffer_name="b0", path="/tmp/x", append=True).render() + ('save-buffer', '-a', '-b', 'b0', '/tmp/x') + """ + + kind = "save_buffer" + command = "save-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects(read_only=True) + + path: str + buffer_name: str | None = None + append: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the flags, optional buffer name, and path.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + out.append(self.path) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/select_layout.py b/src/libtmux/experimental/ops/_ops/select_layout.py new file mode 100644 index 000000000..01dfe1682 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/select_layout.py @@ -0,0 +1,44 @@ +"""The ``select-layout`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SelectLayout(Operation[AckResult]): + """Apply a layout to a window. + + Parameters + ---------- + layout : str or None + A named layout (``even-horizontal``, ``main-vertical``, ...) or a custom + layout string. ``None`` re-applies the current layout. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> SelectLayout(target=WindowId("@1"), layout="main-vertical").render() + ('select-layout', '-t', '@1', 'main-vertical') + >>> SelectLayout(target=WindowId("@1")).render() + ('select-layout', '-t', '@1') + """ + + kind = "select_layout" + command = "select-layout" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True, mutates_layout=True) + + layout: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional layout argument.""" + return (self.layout,) if self.layout is not None else () diff --git a/src/libtmux/experimental/ops/_ops/select_pane.py b/src/libtmux/experimental/ops/_ops/select_pane.py new file mode 100644 index 000000000..0d11562eb --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/select_pane.py @@ -0,0 +1,70 @@ +"""The ``select-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SelectPane(Operation[AckResult]): + """Make a pane active, or move/mark the selection. + + Parameters + ---------- + direction : {"L", "R", "U", "D"} or None + Move to the pane left/right/above/below the target. + last : bool + Select the last (previously active) pane (``-l``). + mark, unmark : bool + Set (``-m``) or clear (``-M``) the marked pane. + zoom : bool + Keep the window zoomed (``-Z``). + title : str or None + Set the pane title (``-T``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SelectPane(target=PaneId("%1")).render() + ('select-pane', '-t', '%1') + >>> SelectPane(target=PaneId("%2"), direction="L", zoom=True).render() + ('select-pane', '-t', '%2', '-L', '-Z') + """ + + kind = "select_pane" + command = "select-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + direction: t.Literal["L", "R", "U", "D"] | None = None + last: bool = False + mark: bool = False + unmark: bool = False + zoom: bool = False + title: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the selection flags.""" + out: list[str] = [] + if self.last: + out.append("-l") + if self.direction is not None: + out.append(f"-{self.direction}") + if self.mark: + out.append("-m") + if self.unmark: + out.append("-M") + if self.zoom: + out.append("-Z") + if self.title is not None: + out.extend(("-T", self.title)) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/select_window.py b/src/libtmux/experimental/ops/_ops/select_window.py new file mode 100644 index 000000000..0ce5412d8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/select_window.py @@ -0,0 +1,30 @@ +"""The ``select-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SelectWindow(Operation[AckResult]): + """Make a window active (``select-window``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> SelectWindow(target=WindowId("@1")).render() + ('select-window', '-t', '@1') + """ + + kind = "select_window" + command = "select-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/send_keys.py b/src/libtmux/experimental/ops/_ops/send_keys.py new file mode 100644 index 000000000..4a2fe5b48 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/send_keys.py @@ -0,0 +1,76 @@ +"""The ``send-keys`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SendKeys(Operation[AckResult]): + """Send keys (input) to a pane. + + Parameters + ---------- + keys : str + The key string to send. + enter : bool + Append an ``Enter`` key after the input. Cannot be combined with + *literal* -- under ``-l`` tmux would type the text "Enter" rather than + pressing Return; send the keys and Enter as two operations instead. + literal : bool + Send keys literally without tmux key-name lookup (``-l``). + suppress_history : bool + Prepend a single space to the command so an ``ignorespace``-configured + shell (``HISTCONTROL=ignorespace``) keeps it out of history -- the same + trick tmuxp uses. No-op when *literal* is set. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SendKeys(target=PaneId("%1"), keys="echo hi", enter=True).render() + ('send-keys', '-t', '%1', 'echo hi', 'Enter') + >>> SendKeys(target=PaneId("%1"), keys="q", literal=True).render() + ('send-keys', '-t', '%1', '-l', 'q') + >>> SendKeys(target=PaneId("%1"), keys="vim", suppress_history=True).render() + ('send-keys', '-t', '%1', ' vim') + """ + + kind = "send_keys" + command = "send-keys" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(writes_input=True) + + keys: str + enter: bool = False + literal: bool = False + suppress_history: bool = False + + def __post_init__(self) -> None: + """Reject literal+enter (fail closed): tmux ``-l`` types "Enter".""" + if self.literal and self.enter: + msg = ( + "send-keys cannot combine literal=True with enter=True; under -l " + "tmux types the text 'Enter' -- send the keys and Enter separately" + ) + raise ValueError(msg) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``send-keys`` flags and the key string.""" + out: list[str] = [] + if self.literal: + out.append("-l") + keys = self.keys + if self.suppress_history and not self.literal: + keys = f" {keys}" + out.append(keys) + if self.enter: + out.append("Enter") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_buffer.py b/src/libtmux/experimental/ops/_ops/set_buffer.py new file mode 100644 index 000000000..e884c97a8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_buffer.py @@ -0,0 +1,54 @@ +"""The ``set-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetBuffer(Operation[AckResult]): + """Set the contents of a paste buffer (``set-buffer``). + + Parameters + ---------- + data : str + The buffer contents. + buffer_name : str or None + The buffer to set (``-b``); tmux picks a name when omitted. + append : bool + Append to the buffer instead of replacing it (``-a``). + + Examples + -------- + >>> SetBuffer(data="hello").render() + ('set-buffer', 'hello') + >>> SetBuffer(buffer_name="b0", data="hi").render() + ('set-buffer', '-b', 'b0', 'hi') + """ + + kind = "set_buffer" + command = "set-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + data: str + buffer_name: str | None = None + append: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the buffer flags and data.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + out.append(self.data) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_environment.py b/src/libtmux/experimental/ops/_ops/set_environment.py new file mode 100644 index 000000000..dac3bce0a --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_environment.py @@ -0,0 +1,64 @@ +"""The ``set-environment`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetEnvironment(Operation[AckResult]): + """Set or unset a session environment variable (``set-environment``). + + Parameters + ---------- + name : str + The variable name. + value : str or None + The value to set (omit when *remove*/*unset*). + global_ : bool + Apply to the global environment (``-g``). + remove : bool + Remove the variable from the environment (``-r``). + unset : bool + Unset the variable (``-u``). + + Examples + -------- + >>> SetEnvironment(name="FOO", value="bar").render() + ('set-environment', 'FOO', 'bar') + >>> SetEnvironment(global_=True, name="FOO", unset=True).render() + ('set-environment', '-g', '-u', 'FOO') + """ + + kind = "set_environment" + command = "set-environment" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + name: str + value: str | None = None + global_: bool = False + remove: bool = False + unset: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the flags, name, and value.""" + out: list[str] = [] + if self.global_: + out.append("-g") + if self.remove: + out.append("-r") + if self.unset: + out.append("-u") + out.append(self.name) + if self.value is not None and not (self.unset or self.remove): + out.append(self.value) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_hook.py b/src/libtmux/experimental/ops/_ops/set_hook.py new file mode 100644 index 000000000..42d1a567d --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_hook.py @@ -0,0 +1,57 @@ +"""The ``set-hook`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetHook(Operation[AckResult]): + """Set or unset a tmux hook (``set-hook``). + + Parameters + ---------- + name : str + The hook name (e.g. ``after-new-window``). + hook_command : str or None + The tmux command to run (omit when *unset*). + global_ : bool + Apply globally (``-g``). + unset : bool + Unset the hook (``-u``). + + Examples + -------- + >>> SetHook(name="after-new-window", hook_command="display hi").render() + ('set-hook', 'after-new-window', 'display hi') + """ + + kind = "set_hook" + command = "set-hook" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + name: str + hook_command: str | None = None + global_: bool = False + unset: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the flags, hook name, and command.""" + out: list[str] = [] + if self.global_: + out.append("-g") + if self.unset: + out.append("-u") + out.append(self.name) + if self.hook_command is not None and not self.unset: + out.append(self.hook_command) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_option.py b/src/libtmux/experimental/ops/_ops/set_option.py new file mode 100644 index 000000000..acbb0e4d2 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_option.py @@ -0,0 +1,80 @@ +"""The ``set-option`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetOption(Operation[AckResult]): + """Set a tmux option (``set-option``); the write counterpart to show-options. + + Parameters + ---------- + option : str + The option name. + value : str or None + The value to set (omit when *unset* is true). + global_, server, window, pane : bool + Scope flags (``-g`` / ``-s`` / ``-w`` / ``-p``). + append : bool + Append to a string/array option (``-a``). + unset : bool + Unset the option (``-u``). + quiet : bool + Suppress errors (``-q``). + + Examples + -------- + >>> SetOption(option="status", value="on").render() + ('set-option', 'status', 'on') + >>> SetOption(global_=True, option="status", value="on").render() + ('set-option', '-g', 'status', 'on') + >>> SetOption(option="status", unset=True).render() + ('set-option', '-u', 'status') + """ + + kind = "set_option" + command = "set-option" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + option: str + value: str | None = None + global_: bool = False + server: bool = False + window: bool = False + pane: bool = False + append: bool = False + unset: bool = False + quiet: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the option flags, name, and value.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.global_: + out.append("-g") + if self.server: + out.append("-s") + if self.window: + out.append("-w") + if self.pane: + out.append("-p") + if self.quiet: + out.append("-q") + if self.unset: + out.append("-u") + out.append(self.option) + if self.value is not None and not self.unset: + out.append(self.value) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_window_option.py b/src/libtmux/experimental/ops/_ops/set_window_option.py new file mode 100644 index 000000000..2fb1cb2b2 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_window_option.py @@ -0,0 +1,62 @@ +"""The ``set-window-option`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetWindowOption(Operation[AckResult]): + """Set a window option (``set-window-option``). + + Parameters + ---------- + option : str + The option name. + value : str or None + The value to set (omit when *unset* is true). + global_ : bool + Apply to all windows (``-g``). + append : bool + Append to a string/array option (``-a``). + unset : bool + Unset the option (``-u``). + + Examples + -------- + >>> SetWindowOption(option="mode-keys", value="vi").render() + ('set-window-option', 'mode-keys', 'vi') + """ + + kind = "set_window_option" + command = "set-window-option" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + option: str + value: str | None = None + global_: bool = False + append: bool = False + unset: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the option flags, name, and value.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.global_: + out.append("-g") + if self.unset: + out.append("-u") + out.append(self.option) + if self.value is not None and not self.unset: + out.append(self.value) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/show_buffer.py b/src/libtmux/experimental/ops/_ops/show_buffer.py new file mode 100644 index 000000000..4faa33e66 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/show_buffer.py @@ -0,0 +1,69 @@ +"""The ``show-buffer`` operation (a read returning buffer contents).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ShowBufferResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ShowBuffer(Operation[ShowBufferResult]): + r"""Show the contents of a paste buffer (``show-buffer``). + + Parameters + ---------- + buffer_name : str or None + The buffer to show (``-b``); the most recent when omitted. + + Examples + -------- + >>> ShowBuffer(buffer_name="b0").render() + ('show-buffer', '-b', 'b0') + >>> ShowBuffer().build_result(returncode=0, stdout=("line1", "line2")).text + 'line1\nline2' + """ + + kind = "show_buffer" + command = "show-buffer" + scope = "server" + result_cls = ShowBufferResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + buffer_name: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-b`` buffer name.""" + if self.buffer_name is not None: + return ("-b", self.buffer_name) + return () + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ShowBufferResult: + """Join the captured lines into the buffer text.""" + return ShowBufferResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + text="\n".join(stdout), + ) diff --git a/src/libtmux/experimental/ops/_ops/show_options.py b/src/libtmux/experimental/ops/_ops/show_options.py new file mode 100644 index 000000000..fae7f71d6 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/show_options.py @@ -0,0 +1,87 @@ +"""The ``show-options`` operation -- typed option pairs.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ShowOptionsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ShowOptions(Operation[ShowOptionsResult]): + """Show options as ``name value`` pairs (``show-options``). + + Parameters + ---------- + global_, server, window : bool + Scope flags (``-g`` / ``-s`` / ``-w``). + include_inherited : bool + Include inherited options (``-A``). + + Examples + -------- + >>> ShowOptions(global_=True).render() + ('show-options', '-g') + >>> ShowOptions().build_result( + ... returncode=0, stdout=("status on", "history-limit 2000") + ... ).options["history-limit"] + '2000' + """ + + kind = "show_options" + command = "show-options" + scope = "session" + result_cls = ShowOptionsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + global_: bool = False + server: bool = False + window: bool = False + include_inherited: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the scope/inheritance flags.""" + out: list[str] = [] + if self.global_: + out.append("-g") + if self.server: + out.append("-s") + if self.window: + out.append("-w") + if self.include_inherited: + out.append("-A") + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ShowOptionsResult: + """Parse ``name value`` lines into a mapping.""" + options: dict[str, str] = {} + for line in stdout: + name, _, value = line.partition(" ") + options[name] = value + return ShowOptionsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + options=options, + ) diff --git a/src/libtmux/experimental/ops/_ops/source_file.py b/src/libtmux/experimental/ops/_ops/source_file.py new file mode 100644 index 000000000..b288c89f4 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/source_file.py @@ -0,0 +1,57 @@ +"""The ``source-file`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SourceFile(Operation[AckResult]): + """Execute tmux commands from a file (``source-file``). + + Parameters + ---------- + path : str + Path to the file to source. + quiet : bool + Suppress errors for missing files (``-q``). + verbose : bool + Show the parsed commands (``-v``). + no_exec : bool + Parse but do not execute (``-n``). + + Examples + -------- + >>> SourceFile(path="~/.tmux.conf").render() + ('source-file', '~/.tmux.conf') + """ + + kind = "source_file" + command = "source-file" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + path: str + quiet: bool = False + verbose: bool = False + no_exec: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the source-file flags and path.""" + out: list[str] = [] + if self.no_exec: + out.append("-n") + if self.quiet: + out.append("-q") + if self.verbose: + out.append("-v") + out.append(self.path) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/split_window.py b/src/libtmux/experimental/ops/_ops/split_window.py new file mode 100644 index 000000000..2bf534d21 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/split_window.py @@ -0,0 +1,109 @@ +"""The ``split-window`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class SplitWindow(Operation[SplitWindowResult]): + """Split a pane, creating a new pane. + + By default the operation appends ``-P -F '#{pane_id}'`` so the new pane's id + is captured on stdout; :meth:`build_result` reads it into + :attr:`~.results.SplitWindowResult.new_pane_id`. + + Parameters + ---------- + horizontal : bool + Split left/right (``-h``) instead of top/bottom (``-v``). + start_directory : str or None + Working directory for the new pane (``-c``). + environment : Mapping[str, str] or None + Environment variables for the new pane (``-e``; tmux 3.0+). + shell : str or None + A shell command to run in the new pane instead of the default shell. + capture : bool + Append ``-P -F '#{pane_id}'`` to capture the new pane id. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SplitWindow(target=PaneId("%1"), horizontal=True).render() + ('split-window', '-t', '%1', '-h', '-P', '-F', '#{pane_id}') + + The ``-e`` environment flag is dropped on tmux older than 3.0: + + >>> op = SplitWindow(target=PaneId("%1"), environment={"E": "1"}) + >>> op.render(version="2.9") + ('split-window', '-t', '%1', '-v', '-P', '-F', '#{pane_id}') + >>> op.render(version="3.3") + ('split-window', '-t', '%1', '-v', '-eE=1', '-P', '-F', '#{pane_id}') + + The created pane id is parsed into the typed result: + + >>> result = op.build_result(returncode=0, stdout=("%2",)) + >>> result.new_pane_id + '%2' + """ + + kind = "split_window" + command = "split-window" + scope = "window" + result_cls = SplitWindowResult + safety = "mutating" + chainable = False # captures a new pane id (-P -F); cannot fold into a ; chain + effects = Effects(creates="pane") + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + horizontal: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + shell: str | None = None + capture: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``split-window`` flags.""" + out: list[str] = ["-h" if self.horizontal else "-v"] + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.capture: + out.extend(("-P", "-F", "#{pane_id}")) + if self.shell is not None: + out.append(self.shell) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> SplitWindowResult: + """Parse the captured new-pane id into the typed result.""" + new_pane_id = stdout[0].strip() if status == "complete" and stdout else None + return SplitWindowResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_pane_id=new_pane_id, + ) diff --git a/src/libtmux/experimental/ops/_ops/start_server.py b/src/libtmux/experimental/ops/_ops/start_server.py new file mode 100644 index 000000000..36a03c624 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/start_server.py @@ -0,0 +1,29 @@ +"""The ``start-server`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class StartServer(Operation[AckResult]): + """Start the tmux server if it is not already running (``start-server``). + + Examples + -------- + >>> StartServer().render() + ('start-server',) + """ + + kind = "start_server" + command = "start-server" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/suspend_client.py b/src/libtmux/experimental/ops/_ops/suspend_client.py new file mode 100644 index 000000000..0aac0b018 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/suspend_client.py @@ -0,0 +1,32 @@ +"""The ``suspend-client`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SuspendClient(Operation[AckResult]): + """Suspend a client (``suspend-client``). + + ``target`` is the client to suspend. + + Examples + -------- + >>> from libtmux.experimental.ops._types import ClientName + >>> SuspendClient(target=ClientName("/dev/pts/1")).render() + ('suspend-client', '-t', '/dev/pts/1') + """ + + kind = "suspend_client" + command = "suspend-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects() diff --git a/src/libtmux/experimental/ops/_ops/swap_pane.py b/src/libtmux/experimental/ops/_ops/swap_pane.py new file mode 100644 index 000000000..bb8cce9b0 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/swap_pane.py @@ -0,0 +1,63 @@ +"""The ``swap-pane`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SwapPane(Operation[AckResult]): + """Swap two panes (``swap-pane``). + + ``target`` is the destination pane (``-t``); ``src_target`` is the source + pane (``-s``). With *up*/*down* and no source, swap with the adjacent pane. + + Parameters + ---------- + detach : bool + Do not change the active pane (``-d``). + up, down : bool + Swap with the pane above (``-U``) / below (``-D``). + zoom : bool + Keep the window zoomed (``-Z``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SwapPane(target=PaneId("%1"), src_target=PaneId("%2")).render() + ('swap-pane', '-t', '%1', '-s', '%2') + >>> SwapPane(target=PaneId("%1"), down=True, detach=True).render() + ('swap-pane', '-t', '%1', '-d', '-D') + """ + + kind = "swap_pane" + command = "swap-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + up: bool = False + down: bool = False + zoom: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the swap flags and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.up: + out.append("-U") + if self.down: + out.append("-D") + if self.zoom: + out.append("-Z") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/swap_window.py b/src/libtmux/experimental/ops/_ops/swap_window.py new file mode 100644 index 000000000..b3237a726 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/swap_window.py @@ -0,0 +1,48 @@ +"""The ``swap-window`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SwapWindow(Operation[AckResult]): + """Swap two windows (``swap-window``). + + ``target`` is the destination (``-t``); ``src_target`` is the source + window (``-s``). + + Parameters + ---------- + detach : bool + Do not change the active window (``-d``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> SwapWindow(target=WindowId("@1"), src_target=WindowId("@2")).render() + ('swap-window', '-t', '@1', '-s', '@2') + """ + + kind = "swap_window" + command = "swap-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-d`` flag and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/switch_client.py b/src/libtmux/experimental/ops/_ops/switch_client.py new file mode 100644 index 000000000..3938d9593 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/switch_client.py @@ -0,0 +1,39 @@ +"""The ``switch-client`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SwitchClient(Operation[AckResult]): + """Switch a client to a session. Produces no output (:class:`AckResult`). + + Uses ``-c`` for the client and ``-t`` for the destination session, so it + does not use the generic target slot. + + Examples + -------- + >>> SwitchClient(client="/dev/pts/3", to_session="$1").render() + ('switch-client', '-c', '/dev/pts/3', '-t', '$1') + """ + + kind = "switch_client" + command = "switch-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + client: str + to_session: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-c -t ``.""" + return ("-c", self.client, "-t", self.to_session) diff --git a/src/libtmux/experimental/ops/_ops/unlink_window.py b/src/libtmux/experimental/ops/_ops/unlink_window.py new file mode 100644 index 000000000..08c7805de --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/unlink_window.py @@ -0,0 +1,41 @@ +"""The ``unlink-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class UnlinkWindow(Operation[AckResult]): + """Unlink a window from a session (``unlink-window``). + + Parameters + ---------- + kill : bool + Also destroy the window if it is no longer linked anywhere (``-k``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> UnlinkWindow(target=WindowId("@1")).render() + ('unlink-window', '-t', '@1') + """ + + kind = "unlink_window" + command = "unlink-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + kill: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-k`` flag.""" + return ("-k",) if self.kill else () diff --git a/src/libtmux/experimental/ops/_read.py b/src/libtmux/experimental/ops/_read.py new file mode 100644 index 000000000..a05c4b98e --- /dev/null +++ b/src/libtmux/experimental/ops/_read.py @@ -0,0 +1,28 @@ +"""Shared helpers for read (list) operations. + +These re-export neo's format-template builder and output parser so the list +operations render the ``-F`` template and parse it with the *exact same* logic +the ORM's reader uses -- one source of truth, no drift. The list ops are a +separate, engine-pluggable read surface that yields immutable +:mod:`~libtmux.experimental.models` snapshots; neo itself is untouched. +""" + +from __future__ import annotations + +from libtmux.formats import FORMAT_SEPARATOR +from libtmux.neo import get_output_format, parse_output + +DEFAULT_LIST_VERSION = "3.2a" +"""tmux version assumed when the caller supplies none (the project floor). + +Rendering and parsing must use the *same* version, so a list op renders its +``-F`` template and parses its output at this version unless an explicit one is +threaded through. Older = a safe field subset on any newer server. +""" + +__all__ = ( + "DEFAULT_LIST_VERSION", + "FORMAT_SEPARATOR", + "get_output_format", + "parse_output", +) diff --git a/src/libtmux/experimental/ops/_types.py b/src/libtmux/experimental/ops/_types.py new file mode 100644 index 000000000..d0220bbca --- /dev/null +++ b/src/libtmux/experimental/ops/_types.py @@ -0,0 +1,343 @@ +"""Typed primitives shared across :mod:`libtmux.experimental.ops`. + +These are the small, inert vocabulary types the operation substrate is built +from: the tmux object :data:`Scope`, the :data:`Safety` tier and execution +:data:`Status` enumerations, the :class:`Effects` descriptor, and the closed +:data:`Target` sum that types a ``-t`` target so an illegal target is a type +error rather than a runtime surprise. + +Everything here is pure: no tmux server is required to construct, render, or +compare any of these values. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +Scope: t.TypeAlias = t.Literal["server", "session", "window", "pane", "client"] +"""The tmux object scope an operation targets. + +``client`` is a view into a live attachment rather than part of the ownership +chain, but it still has operation scope because tmux exposes client-scoped +commands (``detach-client``, ``switch-client``). +""" + +Safety: t.TypeAlias = t.Literal["readonly", "mutating", "destructive"] +"""Coarse safety tier, mirroring the MCP tool-annotation vocabulary. + +``readonly`` reads state, ``mutating`` changes it reversibly, ``destructive`` +removes objects (``kill-session``, ``kill-window``). +""" + +Status: t.TypeAlias = t.Literal["complete", "failed", "skipped", "unknown"] +"""Execution status of a result. + +``complete`` + The command ran and tmux reported success. +``failed`` + The command ran and tmux reported an error. +``skipped`` + The operation was never dispatched (e.g. an earlier command in a chain + failed, or a lazy plan was inspected but not executed). +``unknown`` + The outcome could not be determined (e.g. a control-mode timeout). +""" + + +@dataclass(frozen=True) +class Effects: + """Declarative description of what an operation does to tmux state. + + Carrying effects as typed data (rather than a hand-maintained table in a + downstream consumer) lets MCP annotations and safety tiers derive directly + from the operation. + + Parameters + ---------- + read_only : bool + The operation does not change tmux state. + destructive : bool + The operation removes an object. + idempotent : bool + Re-running the operation has the same effect as running it once. + creates : Scope or None + The scope of the object the operation creates, if any (e.g. + ``split-window`` creates a ``pane``). + writes_input : bool + The operation sends input into a pane (e.g. ``send-keys``). + reads_output : bool + The operation captures output from a pane (e.g. ``capture-pane``). + mutates_layout : bool + The operation rearranges panes/windows (e.g. ``select-layout``). + + Examples + -------- + >>> Effects(read_only=True, idempotent=True) + Effects(read_only=True, destructive=False, idempotent=True, creates=None, + writes_input=False, reads_output=False, mutates_layout=False) + >>> Effects(creates="pane").creates + 'pane' + """ + + read_only: bool = False + destructive: bool = False + idempotent: bool = False + creates: Scope | None = None + writes_input: bool = False + reads_output: bool = False + mutates_layout: bool = False + + +@dataclass(frozen=True, slots=True) +class PaneId: + """A concrete tmux pane id, ``%N``. + + Examples + -------- + >>> PaneId("%1").render() + '%1' + >>> PaneId("1") + Traceback (most recent call last): + ... + ValueError: PaneId must start with '%', got '1' + """ + + value: str + + def __post_init__(self) -> None: + """Validate the id sigil (fail closed).""" + if not self.value.startswith("%"): + msg = f"PaneId must start with '%', got {self.value!r}" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class WindowId: + """A concrete tmux window id, ``@N``. + + Examples + -------- + >>> WindowId("@2").render() + '@2' + """ + + value: str + + def __post_init__(self) -> None: + """Validate the id sigil (fail closed).""" + if not self.value.startswith("@"): + msg = f"WindowId must start with '@', got {self.value!r}" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class SessionId: + """A concrete tmux session id, ``$N``. + + Examples + -------- + >>> SessionId("$0").render() + '$0' + """ + + value: str + + def __post_init__(self) -> None: + """Validate the id sigil (fail closed).""" + if not self.value.startswith("$"): + msg = f"SessionId must start with '$', got {self.value!r}" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class ClientName: + """A tmux client name (a tty path such as ``/dev/pts/3``). + + Examples + -------- + >>> ClientName("/dev/pts/3").render() + '/dev/pts/3' + """ + + value: str + + def __post_init__(self) -> None: + """Reject an empty client name (fail closed).""" + if not self.value: + msg = "ClientName must be a non-empty string" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class NameRef: + """A target addressed by name, optionally requiring an exact match. + + tmux matches names as a prefix by default; prefixing with ``=`` forces an + exact match. + + Examples + -------- + >>> NameRef("work").render() + 'work' + >>> NameRef("work", exact=True).render() + '=work' + """ + + name: str + exact: bool = False + + def __post_init__(self) -> None: + """Reject an empty name (fail closed).""" + if not self.name: + msg = "NameRef name must be a non-empty string" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return f"={self.name}" if self.exact else self.name + + +@dataclass(frozen=True, slots=True) +class IndexRef: + """A target addressed by integer index (window or session index). + + Examples + -------- + >>> IndexRef(0).render() + '0' + >>> IndexRef(2, parent="$1").render() + '$1:2' + """ + + index: int + parent: str | None = None + + def render(self) -> str: + """Render as a tmux ``-t`` target token, qualified by parent if set.""" + if self.parent is not None: + return f"{self.parent}:{self.index}" + return str(self.index) + + +@dataclass(frozen=True, slots=True) +class Special: + """A tmux special target token, e.g. ``{marked}``, ``{last}``, ``{up-of}``. + + The token vocabulary comes from tmux's target-resolution tables (see + ``cmd-find.c``). This wrapper keeps a symbolic target distinct from a + concrete id at the type level. + + Examples + -------- + >>> Special("{marked}").render() + '{marked}' + >>> Special("last").render() + 'last' + """ + + token: str + + def __post_init__(self) -> None: + """Reject an empty token (fail closed).""" + if not self.token: + msg = "Special token must be a non-empty string" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.token + + +@dataclass(frozen=True, slots=True) +class SlotRef: + """A deferred target: "the id of forward slot N", filled at resolve time. + + Carried by an operation built against an object that does not exist yet in + a multi-operation plan. A resolver replaces it with the captured concrete + id plus ``suffix`` before the operation renders; rendering an unresolved + :class:`SlotRef` is a planner bug and raises (see + :meth:`libtmux.experimental.ops.operation.Operation.render`). ``suffix`` + lets a command needing a qualified target -- e.g. ``new-window -t $N:`` -- + reuse a plain captured ``$N``. + + ``part`` selects which captured id to resolve: the slot's own created object + (``"self"``, the default), or an *implicit child* the creator captured -- a + new session/window's first window (``"window"``) or first pane (``"pane"``). + Use the :attr:`window` / :attr:`pane` convenience properties. + + Examples + -------- + >>> SlotRef(0) + SlotRef(slot=0, suffix='', part='self') + >>> SlotRef(0, ":") + SlotRef(slot=0, suffix=':', part='self') + >>> SlotRef(0).pane + SlotRef(slot=0, suffix='', part='pane') + """ + + slot: int + suffix: str = "" + part: t.Literal["self", "window", "pane"] = "self" + + @property + def window(self) -> SlotRef: + """A sub-ref to the first window the slot's creator captured.""" + return SlotRef(self.slot, self.suffix, "window") + + @property + def pane(self) -> SlotRef: + """A sub-ref to the first pane the slot's creator captured.""" + return SlotRef(self.slot, self.suffix, "pane") + + def render(self) -> str: + """Raise -- an unresolved deferred ref cannot be rendered.""" + msg = "cannot render an unresolved SlotRef; resolve the plan first" + raise TypeError(msg) + + +Target: t.TypeAlias = ( + PaneId | WindowId | SessionId | ClientName | NameRef | IndexRef | Special | SlotRef +) +"""The closed sum of everything that can appear as an operation ``-t`` target.""" + + +def render_target(target: Target | None) -> str | None: + """Render any :data:`Target` to its tmux token, or ``None`` for no target. + + Parameters + ---------- + target : Target or None + The typed target to render. + + Returns + ------- + str or None + The ``-t`` token string, or ``None`` when there is no target. + + Examples + -------- + >>> render_target(PaneId("%1")) + '%1' + >>> render_target(None) is None + True + """ + if target is None: + return None + return target.render() diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py new file mode 100644 index 000000000..55ed6118a --- /dev/null +++ b/src/libtmux/experimental/ops/catalog.py @@ -0,0 +1,102 @@ +"""A registry-driven operation catalog (the documentation data source). + +:func:`catalog` walks the operation registry and emits one structured +:class:`CatalogEntry` per operation -- scope, version gates, effects, safety, +result type, and a one-line summary. This is the data a Sphinx ``tmuxop`` domain +directive renders into the operation reference, so the registry is the single +source of truth for both runtime *and* docs and the two cannot drift apart. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.registry import registry as default_registry + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Safety, Scope + from libtmux.experimental.ops.registry import OperationRegistry + + +def _summary(doc: str | None) -> str: + """Return the first non-empty line of a docstring.""" + if not doc: + return "" + for line in doc.strip().splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + +@dataclass(frozen=True) +class CatalogEntry: + """One operation's catalog record, derived from its registry spec.""" + + kind: str + command: str + scope: Scope + safety: Safety + primitive: bool + chainable: bool + result_type: str + min_version: str | None + flag_version_gates: dict[str, str] + effects: dict[str, t.Any] + summary: str + + +def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: + """Build catalog entries for every registered operation, sorted by kind. + + Parameters + ---------- + registry : OperationRegistry or None + The registry to read; defaults to the process-wide registry. + + Returns + ------- + list[CatalogEntry] + + Examples + -------- + >>> from libtmux.experimental.ops import catalog + >>> entries = catalog() + >>> [entry.kind for entry in entries] + ['break_pane', 'capture_pane', 'clear_history', 'delete_buffer', 'detach_client', + 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_server', + 'kill_session', 'kill_window', 'last_pane', 'last_window', 'link_window', + 'list_clients', 'list_panes', 'list_sessions', 'list_windows', 'load_buffer', + 'move_pane', 'move_window', 'new_session', 'new_window', 'next_window', + 'paste_buffer', 'pipe_pane', 'previous_window', 'refresh_client', 'rename_session', + 'rename_window', 'resize_pane', 'resize_window', 'respawn_pane', 'respawn_window', + 'rotate_window', 'run_shell', 'save_buffer', 'select_layout', 'select_pane', + 'select_window', 'send_keys', 'set_buffer', 'set_environment', 'set_hook', + 'set_option', 'set_window_option', 'show_buffer', 'show_options', 'source_file', + 'split_window', 'start_server', 'suspend_client', 'swap_pane', 'swap_window', + 'switch_client', 'unlink_window'] + >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") + >>> capture.scope, capture.safety, capture.result_type + ('pane', 'readonly', 'CapturePaneResult') + >>> capture.flag_version_gates["trim_trailing"] + '3.4' + """ + reg = registry if registry is not None else default_registry + return [ + CatalogEntry( + kind=spec.kind, + command=spec.command, + scope=spec.scope, + safety=spec.safety, + primitive=spec.primitive, + chainable=spec.chainable, + result_type=spec.result_cls.__name__, + min_version=spec.min_version, + flag_version_gates=dict(spec.flag_version_map), + effects=dataclasses.asdict(spec.effects), + summary=_summary(spec.operation_cls.__doc__), + ) + for spec in reg.select() + ] diff --git a/src/libtmux/experimental/ops/exc.py b/src/libtmux/experimental/ops/exc.py new file mode 100644 index 000000000..e9a87ee2e --- /dev/null +++ b/src/libtmux/experimental/ops/exc.py @@ -0,0 +1,121 @@ +"""Exceptions for :mod:`libtmux.experimental.ops`. + +All exceptions subclass :class:`libtmux.exc.LibTmuxException`, so existing +``except LibTmuxException`` handlers keep working while the operation layer +stays isolated under :mod:`libtmux.experimental`. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.exc import LibTmuxException + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + +class OperationError(LibTmuxException): + """Base class for problems building or registering operations.""" + + +class UnknownOperation(OperationError): + """No operation is registered under the requested ``kind``. + + Examples + -------- + >>> raise UnknownOperation("split_window") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.UnknownOperation: no operation registered for + kind 'split_window' + """ + + def __init__(self, kind: str) -> None: + self.kind = kind + msg = f"no operation registered for kind {kind!r}" + super().__init__(msg) + + +class DuplicateOperation(OperationError): + """An operation ``kind`` is already registered. + + Examples + -------- + >>> raise DuplicateOperation("split_window") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.DuplicateOperation: an operation is already + registered for kind 'split_window' + """ + + def __init__(self, kind: str) -> None: + self.kind = kind + msg = f"an operation is already registered for kind {kind!r}" + super().__init__(msg) + + +class VersionUnsupported(OperationError): + """An operation cannot render against the given tmux version. + + Examples + -------- + >>> raise VersionUnsupported("split_window", need="3.0", have="2.9") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.VersionUnsupported: operation 'split_window' + requires tmux >= 3.0 (have 2.9) + """ + + def __init__(self, kind: str, *, need: str, have: str) -> None: + self.kind = kind + self.need = need + self.have = have + msg = f"operation {kind!r} requires tmux >= {need} (have {have})" + super().__init__(msg) + + +class TmuxCommandError(LibTmuxException): + """A tmux command reported failure, raised by ``Result.raise_for_status()``. + + The constructor mirrors :class:`subprocess.CalledProcessError`'s argument + order (``returncode``, ``cmd``, ``stdout``, ``stderr``) so the failure + surface is familiar to anyone who has handled a subprocess error. + + Parameters + ---------- + returncode : int + The tmux process exit code (``-1`` when unknown, e.g. a timeout). + cmd : Sequence[str] + The rendered tmux argv that failed. + stdout : Sequence[str], optional + Captured stdout lines. + stderr : Sequence[str], optional + Captured stderr lines. + + Examples + -------- + >>> raise TmuxCommandError(1, ["split-window", "-t", "%999"], + ... stderr=["can't find pane %999"]) + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.TmuxCommandError: tmux 'split-window -t %999' + failed (exit 1): can't find pane %999 + """ + + def __init__( + self, + returncode: int, + cmd: Sequence[str], + stdout: Sequence[str] | None = None, + stderr: Sequence[str] | None = None, + ) -> None: + self.returncode = returncode + self.cmd = tuple(cmd) + self.stdout = tuple(stdout) if stdout is not None else () + self.stderr = tuple(stderr) if stderr is not None else () + detail = " ".join(self.stderr).strip() + suffix = f": {detail}" if detail else "" + rendered = " ".join(self.cmd) + msg = f"tmux {rendered!r} failed (exit {returncode}){suffix}" + super().__init__(msg) diff --git a/src/libtmux/experimental/ops/execute.py b/src/libtmux/experimental/ops/execute.py new file mode 100644 index 000000000..4197dcfce --- /dev/null +++ b/src/libtmux/experimental/ops/execute.py @@ -0,0 +1,117 @@ +"""Execute an operation through an engine and get back its typed result. + +These two helpers are the whole bridge between the inert operation substrate and +the engines. They share the operation's pure :meth:`~.operation.Operation.render` +and :meth:`~.operation.Operation.build_result`; the *only* difference between the +sync and async paths is ``engine.run(...)`` versus ``await engine.run(...)`` -- +the same sans-I/O split the lazy plan resolver uses. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import CommandRequest + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + +ResultT = t.TypeVar("ResultT", bound="Result") + + +def run( + operation: Operation[ResultT], + engine: TmuxEngine, + *, + version: str | None = None, + tmux_bin: str | pathlib.Path | None = None, +) -> ResultT: + """Render *operation*, run it through *engine*, return its typed result. + + Parameters + ---------- + operation : Operation + The operation to execute. + engine : TmuxEngine + Any synchronous engine. + version : str or None + tmux version to render against (drops unsupported flags); ``None`` + renders every flag. + tmux_bin : str or pathlib.Path or None + Override the tmux binary for this call. + + Returns + ------- + ResultT + The operation's specialized result. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys, run + >>> from libtmux.experimental.ops._types import PaneId + >>> from libtmux.experimental.engines import CommandResult + >>> class EchoEngine: + ... def run(self, request): + ... return CommandResult(cmd=("tmux", *request.args), returncode=0) + ... def run_batch(self, requests): + ... return [self.run(r) for r in requests] + >>> result = run(SendKeys(target=PaneId("%1"), keys="echo hi"), EchoEngine()) + >>> result.ok + True + >>> result.argv + ('send-keys', '-t', '%1', 'echo hi') + """ + rendered = operation.render(version=version) + raw = engine.run(CommandRequest.from_args(*rendered, tmux_bin=tmux_bin)) + return operation.build_result( + argv=rendered, + returncode=raw.returncode, + stdout=raw.stdout, + stderr=raw.stderr, + version=version, + ) + + +async def arun( + operation: Operation[ResultT], + engine: AsyncTmuxEngine, + *, + version: str | None = None, + tmux_bin: str | pathlib.Path | None = None, +) -> ResultT: + """Async sibling of :func:`run`, sharing the same render/build path. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import arun, CapturePane + >>> from libtmux.experimental.ops._types import PaneId + >>> from libtmux.experimental.engines import CommandResult + >>> class AsyncEchoEngine: + ... async def run(self, request): + ... return CommandResult( + ... cmd=("tmux", *request.args), + ... stdout=("line-1", "line-2"), + ... returncode=0, + ... ) + ... async def run_batch(self, requests): + ... return [await self.run(r) for r in requests] + >>> async def main(): + ... return await arun(CapturePane(target=PaneId("%1")), AsyncEchoEngine()) + >>> result = asyncio.run(main()) + >>> result.lines + ('line-1', 'line-2') + """ + rendered = operation.render(version=version) + raw = await engine.run(CommandRequest.from_args(*rendered, tmux_bin=tmux_bin)) + return operation.build_result( + argv=rendered, + returncode=raw.returncode, + stdout=raw.stdout, + stderr=raw.stderr, + version=version, + ) diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py new file mode 100644 index 000000000..ae947cf52 --- /dev/null +++ b/src/libtmux/experimental/ops/operation.py @@ -0,0 +1,317 @@ +"""The base :class:`Operation` value. + +An operation is an immutable, keyword-only dataclass that carries everything an +engine needs to render a tmux command, validate it against a tmux version, and +type its result -- but it never dispatches. Operation classes declare their +stable metadata (``kind``, ``command``, ``scope``, ``result_cls``, effects, +safety, version gates) as class variables, so the class itself is the single +source of truth that the registry, serializer, and docs catalog all read from. + +Rendering is pure: :meth:`Operation.render` produces an argv tuple from the +operation's fields, dropping version-gated flags an older tmux cannot accept, +and :meth:`Operation.build_result` adapts raw tmux output into the operation's +typed result -- both without touching a tmux server. +""" + +from __future__ import annotations + +import types +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import render_target +from libtmux.experimental.ops.exc import VersionUnsupported +from libtmux.experimental.ops.results import Result, status_for +from libtmux.neo import _normalize_tmux_version + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from libtmux.experimental.ops._chain import OpChain + from libtmux.experimental.ops._types import ( + Effects, + Safety, + Scope, + Status, + Target, + ) + +ResultT = t.TypeVar("ResultT", bound=Result) + + +@dataclass(frozen=True, kw_only=True) +class Operation(t.Generic[ResultT]): + """Inert, typed description of one tmux command. + + Subclasses declare the class-level metadata and provide :meth:`args` (the + positional tokens after the target). The instance fields describe one + concrete invocation. + + Parameters + ---------- + target : Target or None + The ``-t`` target, or ``None`` for "no explicit target". + src_target : Target or None + The ``-s`` source target for dual-target commands (``swap-pane``, + ``join-pane``, ``link-window``, ...), or ``None`` when unused. + + Notes + ----- + Class variables (set by subclasses): + + ``kind`` + Stable discriminator, also the registry key (e.g. ``"split_window"``). + ``command`` + The tmux command name (e.g. ``"split-window"``). + ``scope`` + The tmux object scope (:data:`~._types.Scope`). + ``result_cls`` + The :class:`~.results.Result` subclass this operation returns. + ``chainable`` + Whether the command may be folded into a one-dispatch sequence. + ``primitive`` + ``True`` when the operation wraps one tmux command; ``False`` when it is + composed from others (e.g. a synthesized server-exists check). + ``safety`` + The :data:`~._types.Safety` tier. + ``effects`` + An :class:`~._types.Effects` descriptor. + ``min_version`` + Minimum tmux version the whole operation requires, if any. + ``flag_version_map`` + Maps a feature label to the minimum tmux version that supports it; the + operation consults this in :meth:`args` to drop unsupported flags. + """ + + target: Target | None = None + src_target: Target | None = None + + kind: t.ClassVar[str] + command: t.ClassVar[str] + scope: t.ClassVar[Scope] + result_cls: t.ClassVar[type[Result]] + chainable: t.ClassVar[bool] = True + primitive: t.ClassVar[bool] = True + safety: t.ClassVar[Safety] = "mutating" + effects: t.ClassVar[Effects] + min_version: t.ClassVar[str | None] = None + flag_version_map: t.ClassVar[Mapping[str, str]] = types.MappingProxyType({}) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Return the positional argument tokens after the target. + + Override per operation. ``version`` is the tmux version the engine will + run against (``None`` means "assume latest"); use :meth:`flag_available` + to drop flags an older tmux cannot accept. + + Returns + ------- + tuple[str, ...] + """ + return () + + def render(self, *, version: str | None = None) -> tuple[str, ...]: + """Render this operation as a tmux argv tuple. + + Parameters + ---------- + version : str or None + The tmux version to render against. ``None`` renders every flag. + + Returns + ------- + tuple[str, ...] + ``(command, ["-t", target], *args)``. + + Raises + ------ + ~libtmux.experimental.ops.exc.VersionUnsupported + When the running tmux is older than :attr:`min_version`. + TypeError + When the target is an unresolved + :class:`~._types.SlotRef` (a planner bug). + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> SendKeys(target=PaneId("%1"), keys="echo hi", enter=True).render() + ('send-keys', '-t', '%1', 'echo hi', 'Enter') + """ + self.check_version(version) + rendered: list[str] = [self.command] + token = render_target(self.target) + if token is not None: + rendered.extend(("-t", token)) + rendered.extend(self.args(version=version)) + return tuple(rendered) + + def check_version(self, version: str | None) -> None: + """Raise if *version* is older than this operation's :attr:`min_version`. + + Parameters + ---------- + version : str or None + The running tmux version, or ``None`` to skip the check. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> SplitWindow(target=PaneId("%1")).check_version("3.4") + """ + if version is None or self.min_version is None: + return + if _normalize_tmux_version(version) < _normalize_tmux_version(self.min_version): + raise VersionUnsupported( + self.kind, + need=self.min_version, + have=version, + ) + + def flag_available(self, label: str, version: str | None) -> bool: + """Whether a version-gated feature is available on *version*. + + Parameters + ---------- + label : str + A key in :attr:`flag_version_map`. + version : str or None + The running tmux version, or ``None`` to assume latest. + + Returns + ------- + bool + ``True`` when the feature is ungated, unknown, or supported. + + Examples + -------- + >>> from libtmux.experimental.ops import CapturePane + >>> from libtmux.experimental.ops._types import PaneId + >>> op = CapturePane(target=PaneId("%1"), trim_trailing=True) + >>> op.flag_available("trim_trailing", "3.4") + True + >>> op.flag_available("trim_trailing", "3.0") + False + """ + need = self.flag_version_map.get(label) + if need is None or version is None: + return True + return _normalize_tmux_version(version) >= _normalize_tmux_version(need) + + def src_args(self) -> tuple[str, ...]: + """Render the ``-s`` source target, or ``()`` when there is none. + + Dual-target commands call this from :meth:`args` to emit their source. + + Examples + -------- + >>> from libtmux.experimental.ops import SwapPane + >>> from libtmux.experimental.ops._types import PaneId + >>> SwapPane(target=PaneId("%1"), src_target=PaneId("%2")).src_args() + ('-s', '%2') + """ + token = render_target(self.src_target) + return ("-s", token) if token is not None else () + + def build_result( + self, + *, + returncode: int, + argv: tuple[str, ...] | None = None, + stdout: Sequence[str] = (), + stderr: Sequence[str] = (), + version: str | None = None, + ) -> ResultT: + """Adapt raw tmux output into this operation's typed result. + + Parameters + ---------- + returncode : int + tmux exit code. + argv : tuple[str, ...] or None + The argv that produced the output; rendered from this operation when + omitted. + stdout, stderr : Sequence[str] + Captured output lines. + version : str or None + The tmux version the output was produced against. Used to render + *argv* when omitted, and passed to :meth:`_make_result` so payload + parsing can match the version-gated render (e.g. a ``-F`` template). + + Returns + ------- + ResultT + An instance of :attr:`result_cls`. + """ + rendered = argv if argv is not None else self.render(version=version) + status = status_for(returncode, stderr) + return self._make_result( + rendered, + status, + returncode, + tuple(stdout), + tuple(stderr), + version=version, + ) + + def result_with_status( + self, + status: Status, + *, + version: str | None = None, + returncode: int = 0, + stdout: Sequence[str] = (), + stderr: Sequence[str] = (), + ) -> ResultT: + """Build a result with an explicit *status* (no status inference). + + Used when the status is known out of band -- e.g. a chained operation + whose ``;`` group ran but whose own outcome must be marked ``skipped`` + because an earlier member failed. + """ + return self._make_result( + self.render(version=version), + status, + returncode, + tuple(stdout), + tuple(stderr), + version=version, + ) + + def then(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Compose with another operation (or chain) into an :class:`OpChain`.""" + from libtmux.experimental.ops._chain import OpChain + + return OpChain((self,)).then(other) + + def __rshift__(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Compose operations with ``>>`` into an :class:`OpChain`.""" + return self.then(other) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ResultT: + """Construct the result instance; override to populate typed payloads. + + ``version`` is the tmux version the output was produced against; payload + parsers that depend on a version-gated render (read ops) use it. The base + implementation and simple overrides ignore it. + """ + return t.cast( + "ResultT", + self.result_cls( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + ), + ) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py new file mode 100644 index 000000000..aad70b331 --- /dev/null +++ b/src/libtmux/experimental/ops/plan.py @@ -0,0 +1,354 @@ +"""Lazy, deferred-resolution plans over the typed operation spine. + +A :class:`LazyPlan` records operations without touching tmux, so a plan can be +inspected, serialized, and executed later. Operations may target the *result of +an earlier operation* via a :class:`~._types.SlotRef` (e.g. send keys to the pane +a split is about to create); the plan resolves those references from captured ids +at execution time. + +Resolution is a sans-I/O generator -- the same yield-operation / resume-with- +result trampoline the chainable-commands prototype uses. The sync +:meth:`LazyPlan.execute` and async :meth:`LazyPlan.aexecute` drivers differ only +in ``run(...)`` versus ``await arun(...)``; the resolution logic is written once. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.ops._chain import ( + attribute, + attribute_marked, + render_chain, + render_marked, +) +from libtmux.experimental.ops._types import ( + PaneId, + SessionId, + SlotRef, + Special, + WindowId, +) +from libtmux.experimental.ops.exc import OperationError +from libtmux.experimental.ops.execute import arun, run +from libtmux.experimental.ops.planner import Planner, SequentialPlanner +from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict + +if t.TYPE_CHECKING: + from collections.abc import Generator, Iterator + + from typing_extensions import Self + + from libtmux.experimental.engines.base import ( + AsyncTmuxEngine, + CommandResult, + TmuxEngine, + ) + from libtmux.experimental.ops._chain import OpChain + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class _Single: + """Drive request: run one resolved operation and return its typed result.""" + + op: Operation[t.Any] + + +@dataclass(frozen=True) +class _Chain: + """Drive request: dispatch a folded ``;`` chain and return the merged result.""" + + argv: tuple[str, ...] + + +def _target_from_id(value: str) -> Target: + """Map a captured concrete id back to its typed target.""" + if value.startswith("%"): + return PaneId(value) + if value.startswith("@"): + return WindowId(value) + if value.startswith("$"): + return SessionId(value) + return Special(value) + + +def _resolve_slot( + ref: SlotRef, + bindings: dict[int | tuple[int, str], str], +) -> Target: + """Map a :class:`SlotRef` to the captured concrete target it points at.""" + key: int | tuple[int, str] = ( + ref.slot if ref.part == "self" else (ref.slot, ref.part) + ) + try: + concrete = bindings[key] + ref.suffix + except KeyError as error: + msg = ( + f"slot {ref.slot} (part {ref.part!r}) has no captured id yet; a plan " + f"step can only reference an earlier step that creates that object" + ) + raise OperationError(msg) from error + return _target_from_id(concrete) + + +def _resolve( + operation: Operation[t.Any], + bindings: dict[int | tuple[int, str], str], +) -> Operation[t.Any]: + """Substitute any :class:`SlotRef` ``target``/``src_target`` with its id.""" + changes: dict[str, Target] = {} + if isinstance(operation.target, SlotRef): + changes["target"] = _resolve_slot(operation.target, bindings) + if isinstance(operation.src_target, SlotRef): + changes["src_target"] = _resolve_slot(operation.src_target, bindings) + if not changes: + return operation + return dataclasses.replace(operation, **changes) + + +def _resolve_src( + operation: Operation[t.Any], + bindings: dict[int | tuple[int, str], str], +) -> Operation[t.Any]: + """Resolve only a :class:`SlotRef` ``src_target``. + + A ``{marked}`` decorate's ``target`` is this same fold's create, which has no + captured id yet -- it is addressed through tmux's ``{marked}`` register by + :func:`~._chain.render_marked`, so only ``src_target`` (which references an + already-bound earlier step) is substituted here. + """ + if isinstance(operation.src_target, SlotRef): + return dataclasses.replace( + operation, + src_target=_resolve_slot(operation.src_target, bindings), + ) + return operation + + +@dataclass(frozen=True) +class PlanResult: + """The outcome of executing a :class:`LazyPlan`. + + Parameters + ---------- + results : tuple[Result, ...] + One result per recorded operation, in order. + bindings : dict[int | tuple[int, str], str] + Maps a creating step's index to the concrete id it produced; a + ``(index, part)`` key holds an implicit child's id (e.g. a new window's + first pane), bound when the creator opts into capturing it. + """ + + results: tuple[Result, ...] + bindings: dict[int | tuple[int, str], str] = field(default_factory=dict) + + @property + def ok(self) -> bool: + """Whether every step completed successfully.""" + return all(result.ok for result in self.results) + + def raise_for_status(self) -> Self: + """Raise on the first failed step; return ``self`` when all are OK.""" + for result in self.results: + result.raise_for_status() + return self + + +class LazyPlan: + """Record operations now; resolve refs and execute them later. + + Examples + -------- + Build a plan that splits a window then types into the *new* pane, and run it + against the in-memory concrete engine (no tmux required): + + >>> from libtmux.experimental.ops import SplitWindow, SendKeys + >>> from libtmux.experimental.ops._types import WindowId + >>> from libtmux.experimental.engines import ConcreteEngine + >>> plan = LazyPlan() + >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) + >>> _ = plan.add(SendKeys(target=pane, keys="vim", enter=True)) + >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome.bindings + {0: '%1'} + >>> outcome.results[1].argv + ('send-keys', '-t', '%1', 'vim', 'Enter') + """ + + def __init__(self) -> None: + self._operations: list[Operation[t.Any]] = [] + + def add(self, operation: Operation[t.Any]) -> SlotRef: + """Record an operation; return a :class:`SlotRef` to its eventual id. + + The returned ref can be used as the ``target`` of a later operation to + address the object this one creates. + """ + self._operations.append(operation) + return SlotRef(len(self._operations) - 1) + + @property + def operations(self) -> tuple[Operation[t.Any], ...]: + """The recorded operations, in order.""" + return tuple(self._operations) + + def __len__(self) -> int: + """Return the number of recorded operations.""" + return len(self._operations) + + def __iter__(self) -> Iterator[Operation[t.Any]]: + """Iterate recorded operations in order.""" + return iter(self._operations) + + def to_list(self) -> list[dict[str, t.Any]]: + """Serialize the whole plan to a list of plain operation dicts.""" + return [operation_to_dict(operation) for operation in self._operations] + + @classmethod + def from_list(cls, data: t.Sequence[t.Mapping[str, t.Any]]) -> LazyPlan: + """Reconstruct a plan from :meth:`to_list` output.""" + plan = cls() + plan._operations = [operation_from_dict(item) for item in data] + return plan + + def add_chain(self, chain: OpChain) -> None: + """Record every operation of an :class:`~._chain.OpChain` in order.""" + self._operations.extend(chain.ops) + + def preview(self, *, version: str | None = None) -> list[tuple[str, ...] | None]: + """Render each recorded operation's argv without executing it. + + A pure dry-run: an operation whose target is still an unresolved + :class:`~._types.SlotRef` renders as ``None`` (it needs a captured id + from an earlier step, supplied only at execution time). + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, SendKeys + >>> from libtmux.experimental.ops._types import WindowId + >>> plan = LazyPlan() + >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) + >>> _ = plan.add(SendKeys(target=pane, keys="vim", enter=True)) + >>> plan.preview() + [('split-window', '-t', '@1', '-v', '-P', '-F', '#{pane_id}'), None] + """ + + def _render(op: Operation[t.Any]) -> tuple[str, ...] | None: + try: + return op.render(version=version) + except TypeError: # unresolved SlotRef -- needs a captured id + return None + + return [_render(op) for op in self._operations] + + def _drive( + self, + version: str | None, + planner: Planner, + ) -> Generator[_Single | _Chain, t.Any, PlanResult]: + """Sans-I/O resolution core driven by a :class:`~.planner.Planner`. + + Yields a :class:`_Single` (driver runs one op, returns its + :class:`~.results.Result`) or a :class:`_Chain` (driver returns the + merged :class:`~..engines.base.CommandResult`, attributed per op here). + The sync and async drivers differ only in ``run`` vs ``await arun`` and + ``engine.run`` vs ``await engine.run``. + """ + bindings: dict[int | tuple[int, str], str] = {} + results: dict[int, Result] = {} + for step in planner.plan(self._operations): + if step.marked: + create_idx, *decorate_idx = step.indices + create = _resolve(self._operations[create_idx], bindings) + decorates = [ + _resolve_src(self._operations[i], bindings) for i in decorate_idx + ] + merged: CommandResult = yield _Chain( + render_marked(create, decorates, version), + ) + created, decorated, new_id = attribute_marked( + create, + decorates, + merged, + version, + ) + results[create_idx] = created + results.update(zip(decorate_idx, decorated, strict=True)) + if new_id is not None: + bindings[create_idx] = new_id + elif len(step.indices) == 1: + index = step.indices[0] + result = yield _Single(_resolve(self._operations[index], bindings)) + results[index] = result + if result.created_id is not None: + bindings[index] = result.created_id + for sub_part, sub_id in result.created_subids.items(): + bindings[index, sub_part] = sub_id + else: + group = [_resolve(self._operations[i], bindings) for i in step.indices] + merged = yield _Chain(render_chain(group, version)) + results.update( + zip(step.indices, attribute(group, merged, version), strict=True), + ) + ordered = tuple(results[slot] for slot in range(len(self._operations))) + return PlanResult(ordered, bindings) + + def execute( + self, + engine: TmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, + ) -> PlanResult: + """Resolve and execute the plan synchronously. + + The *planner* decides dispatch grouping; it defaults to + :class:`~.planner.SequentialPlanner` (one tmux call per op). Pass a + :class:`~.planner.FoldingPlanner` or :class:`~.planner.MarkedPlanner` to + fold dispatches -- the :class:`PlanResult` is identical, only the + dispatch count changes. + """ + gen = self._drive(version, planner or SequentialPlanner()) + try: + request = next(gen) + while True: + request = gen.send(self._dispatch(request, engine, version)) + except StopIteration as stop: + return t.cast("PlanResult", stop.value) + + def _dispatch( + self, + request: _Single | _Chain, + engine: TmuxEngine, + version: str | None, + ) -> t.Any: + """Run one drive request synchronously.""" + if isinstance(request, _Chain): + return engine.run(CommandRequest.from_args(*request.argv)) + return run(request.op, engine, version=version) + + async def aexecute( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, + ) -> PlanResult: + """Resolve and execute the plan asynchronously (same resolution core).""" + gen = self._drive(version, planner or SequentialPlanner()) + try: + request = next(gen) + while True: + if isinstance(request, _Chain): + raw = await engine.run(CommandRequest.from_args(*request.argv)) + request = gen.send(raw) + else: + request = gen.send(await arun(request.op, engine, version=version)) + except StopIteration as stop: + return t.cast("PlanResult", stop.value) diff --git a/src/libtmux/experimental/ops/planner.py b/src/libtmux/experimental/ops/planner.py new file mode 100644 index 000000000..53f5f23aa --- /dev/null +++ b/src/libtmux/experimental/ops/planner.py @@ -0,0 +1,165 @@ +"""Pluggable planners that decide how a lazy plan dispatches. + +A planner is pure policy: given the recorded operations it returns a list of +:class:`PlanStep` units, and :meth:`~.plan.LazyPlan.execute` runs them. Swapping +planners changes *how many tmux dispatches* a plan costs without changing its +result, so strategies can be A/B-tested (same :class:`~.plan.PlanResult`, +differing dispatch count). + +- :class:`SequentialPlanner` -- one dispatch per operation (the safe default). +- :class:`FoldingPlanner` -- fold maximal runs of chainable ops into one + ``tmux a ; b`` dispatch. +- :class:`MarkedPlanner` -- additionally fold a pane creation plus the chainable + ops that decorate it into a *single* dispatch via tmux's ``{marked}`` register + (the chainable-commands lone-pane optimization). +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import SlotRef + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.ops.operation import Operation + + +@dataclass(frozen=True) +class PlanStep: + """One dispatch unit. + + A single op (``len(indices) == 1``), a ``;``-folded chain (more, ``marked`` + false), or a ``{marked}`` fold (``marked`` true: ``indices[0]`` is the pane + creation, the rest decorate it through ``{marked}``). + """ + + indices: tuple[int, ...] + marked: bool = False + + +@t.runtime_checkable +class Planner(t.Protocol): + """Decides the dispatch units for a plan's operations.""" + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Return the ordered dispatch units for *operations*.""" + ... + + +class SequentialPlanner: + """Dispatch each operation on its own (one tmux call per op).""" + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """One single-op step per operation. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> SequentialPlanner().plan([SendKeys(target=PaneId("%1"), keys="a")]) + [PlanStep(indices=(0,), marked=False)] + """ + return [PlanStep((index,)) for index in range(len(operations))] + + +def _fold_runs(operations: Sequence[Operation[t.Any]], start: int) -> list[PlanStep]: + """Group maximal runs of chainable ops from *start* into chain/single steps.""" + steps: list[PlanStep] = [] + index = start + total = len(operations) + while index < total: + if operations[index].chainable: + cursor = index + while cursor < total and operations[cursor].chainable: + cursor += 1 + steps.append(PlanStep(tuple(range(index, cursor)))) + index = cursor + else: + steps.append(PlanStep((index,))) + index += 1 + return steps + + +class FoldingPlanner: + """Fold maximal runs of chainable ops into one ``;`` dispatch each.""" + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Chain consecutive chainable ops; dispatch the rest alone. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> ops = [ + ... SendKeys(target=PaneId("%1"), keys="a"), + ... SendKeys(target=PaneId("%1"), keys="b"), + ... ] + >>> FoldingPlanner().plan(ops) + [PlanStep(indices=(0, 1), marked=False)] + """ + return _fold_runs(operations, 0) + + +class MarkedPlanner: + """Fold a pane creation + the chainable ops that decorate it into one call. + + When a pane-creating op (``effects.creates == "pane"``) is immediately + followed by chainable ops that target *its* slot, they collapse into a single + ``split-window … ; select-pane -m ; … -t {marked} … ; select-pane -M`` + dispatch. Anything else folds like :class:`FoldingPlanner`. + """ + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Emit ``{marked}`` folds where possible, else fold normally. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, SendKeys + >>> from libtmux.experimental.ops._types import SlotRef, WindowId + >>> ops = [ + ... SplitWindow(target=WindowId("@1")), + ... SendKeys(target=SlotRef(0), keys="vim", enter=True), + ... ] + >>> MarkedPlanner().plan(ops) + [PlanStep(indices=(0, 1), marked=True)] + """ + steps: list[PlanStep] = [] + index = 0 + total = len(operations) + while index < total: + decorates = _marked_decorates(operations, index) + if decorates: + steps.append(PlanStep((index, *decorates), marked=True)) + index = decorates[-1] + 1 + else: + run = _fold_runs(operations, index)[0] + steps.append(run) + index = run.indices[-1] + 1 + return steps + + +def _marked_decorates( + operations: Sequence[Operation[t.Any]], + index: int, +) -> tuple[int, ...]: + """Return the indices of chainable ops decorating a pane created at *index*. + + Empty unless *index* is a pane creation followed by at least one chainable op + whose target is that creation's :class:`SlotRef`. + """ + creator = operations[index] + if creator.effects.creates != "pane" or creator.chainable: + return () + decorates: list[int] = [] + cursor = index + 1 + while cursor < len(operations): + op = operations[cursor] + if op.chainable and op.target == SlotRef(index): + decorates.append(cursor) + cursor += 1 + else: + break + return tuple(decorates) diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py new file mode 100644 index 000000000..e0538ac6a --- /dev/null +++ b/src/libtmux/experimental/ops/registry.py @@ -0,0 +1,212 @@ +"""The operation registry: one entry per operation ``kind``. + +The registry is the single source of truth that runtime dispatch, serialization, +and the (planned) docs catalog all read from. Each entry is an :class:`OpSpec` +derived from an :class:`~.operation.Operation` subclass's class variables, so the +operation class itself remains authoritative -- the registry just indexes it. + +Lookups fail closed: an unknown ``kind`` raises +:class:`~.exc.UnknownOperation`, and registering a duplicate raises +:class:`~.exc.DuplicateOperation` unless ``replace=True``. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.exc import DuplicateOperation, UnknownOperation + +if t.TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping + + from libtmux.experimental.ops._types import Effects, Safety, Scope + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + +OpT = t.TypeVar("OpT", bound="type[Operation[t.Any]]") + + +@dataclass(frozen=True) +class OpSpec: + """Indexed metadata for one operation, derived from its class variables. + + Attributes mirror the operation class variables documented on + :class:`~.operation.Operation`. + """ + + kind: str + command: str + scope: Scope + operation_cls: type[Operation[t.Any]] + result_cls: type[Result] + chainable: bool + primitive: bool + safety: Safety + effects: Effects + min_version: str | None + flag_version_map: Mapping[str, str] + + @classmethod + def from_operation(cls, operation_cls: type[Operation[t.Any]]) -> OpSpec: + """Build a spec by reading an operation class's class variables. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> spec = OpSpec.from_operation(SplitWindow) + >>> spec.kind, spec.command, spec.scope + ('split_window', 'split-window', 'window') + """ + return cls( + kind=operation_cls.kind, + command=operation_cls.command, + scope=operation_cls.scope, + operation_cls=operation_cls, + result_cls=operation_cls.result_cls, + chainable=operation_cls.chainable, + primitive=operation_cls.primitive, + safety=operation_cls.safety, + effects=operation_cls.effects, + min_version=operation_cls.min_version, + flag_version_map=operation_cls.flag_version_map, + ) + + +class OperationRegistry: + """A fail-closed index of operations keyed by ``kind``. + + Examples + -------- + >>> from libtmux.experimental.ops import registry, SplitWindow + >>> "split_window" in registry + True + >>> registry.get("split_window").scope + 'window' + >>> registry.operation("split_window") is SplitWindow + True + >>> registry.get("does_not_exist") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.UnknownOperation: no operation registered for + kind 'does_not_exist' + """ + + def __init__(self) -> None: + self._specs: dict[str, OpSpec] = {} + + def register( + self, + operation_cls: type[Operation[t.Any]], + *, + replace: bool = False, + ) -> None: + """Register an operation class. + + Parameters + ---------- + operation_cls : type[Operation] + The operation class to index. + replace : bool + Allow replacing an existing registration for the same ``kind``. + + Raises + ------ + ~libtmux.experimental.ops.exc.DuplicateOperation + When ``kind`` is already registered and ``replace`` is ``False``. + """ + kind = operation_cls.kind + if not replace and kind in self._specs: + raise DuplicateOperation(kind) + self._specs[kind] = OpSpec.from_operation(operation_cls) + + def unregister(self, kind: str) -> None: + """Remove an operation registration. + + Raises + ------ + ~libtmux.experimental.ops.exc.UnknownOperation + When ``kind`` is not registered. + """ + if kind not in self._specs: + raise UnknownOperation(kind) + del self._specs[kind] + + def get(self, kind: str) -> OpSpec: + """Return the :class:`OpSpec` for ``kind`` or fail closed. + + Raises + ------ + ~libtmux.experimental.ops.exc.UnknownOperation + When ``kind`` is not registered. + """ + try: + return self._specs[kind] + except KeyError as error: + raise UnknownOperation(kind) from error + + def operation(self, kind: str) -> type[Operation[t.Any]]: + """Return the operation class registered for ``kind``.""" + return self.get(kind).operation_cls + + def select( + self, + predicate: Callable[[OpSpec], bool] | None = None, + ) -> list[OpSpec]: + """Return registered specs (optionally filtered), sorted by ``kind``. + + Named ``select`` rather than ``list`` so the ``-> list[OpSpec]`` return + annotation is not shadowed by the method name. + + Parameters + ---------- + predicate : callable, optional + Keep only specs for which ``predicate(spec)`` is true. + + Examples + -------- + >>> from libtmux.experimental.ops import registry + >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] + ['capture_pane', 'display_message', 'has_session', 'list_clients', + 'list_panes', 'list_sessions', 'list_windows', 'show_buffer', + 'show_options'] + """ + specs = sorted(self._specs.values(), key=lambda spec: spec.kind) + if predicate is None: + return specs + return [spec for spec in specs if predicate(spec)] + + def kinds(self) -> tuple[str, ...]: + """Return all registered kinds, sorted.""" + return tuple(sorted(self._specs)) + + def __contains__(self, kind: object) -> bool: + """Whether ``kind`` is registered.""" + return kind in self._specs + + def __iter__(self) -> Iterator[OpSpec]: + """Iterate specs sorted by ``kind``.""" + return iter(self.select()) + + def __len__(self) -> int: + """Return the number of registered operations.""" + return len(self._specs) + + +registry = OperationRegistry() +"""The default, process-wide operation registry.""" + + +def register(operation_cls: OpT) -> OpT: + """Class decorator that registers an operation in the default registry. + + Returns the class unchanged, so it can decorate a class definition. + + Examples + -------- + >>> from libtmux.experimental.ops import registry + >>> "send_keys" in registry + True + """ + registry.register(operation_cls) + return operation_cls diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py new file mode 100644 index 000000000..ae34b9463 --- /dev/null +++ b/src/libtmux/experimental/ops/results.py @@ -0,0 +1,353 @@ +"""Typed result values for :mod:`libtmux.experimental.ops`. + +A :class:`Result` is the uniform shape every engine returns for the same +operation: the operation that produced it, the rendered argv, an execution +:data:`~libtmux.experimental.ops._types.Status`, and the captured tmux output. +Specialized payloads (a new pane id, captured lines) live on subclasses defined +next to their operations. + +Results never raise on construction. Raising is opt-in via +:meth:`Result.raise_for_status`, mirroring +:meth:`subprocess.CompletedProcess.check_returncode`. *How* an engine treats a +failed result is the engine's policy: the classic engine raises in its facade to +match today's behavior, while newer engines hand the result back and let the +caller decide. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.models.snapshots import ( + ClientSnapshot, + PaneSnapshot, + ServerSnapshot, + SessionSnapshot, + WindowSnapshot, +) +from libtmux.experimental.ops.exc import TmuxCommandError + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from typing_extensions import Self + + from libtmux.experimental.ops._types import Status + from libtmux.experimental.ops.operation import Operation + + +def status_for(returncode: int, stderr: t.Sequence[str]) -> Status: + """Derive a result :data:`~._types.Status` from a tmux outcome. + + tmux frequently reports a failure as stderr text while still exiting ``0``, + so non-empty stderr counts as a failure here -- a deliberate divergence from + a returncode-only test (see :meth:`Result.raise_for_status`). + + Parameters + ---------- + returncode : int + The tmux process exit code. + stderr : Sequence[str] + Captured stderr lines. + + Returns + ------- + Status + + Examples + -------- + >>> status_for(0, []) + 'complete' + >>> status_for(1, []) + 'failed' + >>> status_for(0, ["no current session"]) + 'failed' + """ + if returncode == 0 and not stderr: + return "complete" + return "failed" + + +@dataclass(frozen=True) +class Result: + """Base result for an executed (or simulated) operation. + + Parameters + ---------- + operation : Operation + The operation this result came from. + argv : tuple[str, ...] + The rendered tmux argv that produced this result. + status : Status + Execution status. + returncode : int + tmux exit code (``-1`` when unknown, e.g. a timeout). + stdout, stderr : tuple[str, ...] + Captured output lines. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> result = SendKeys(target=PaneId("%1"), keys="echo hi").build_result( + ... argv=("send-keys", "-t", "%1", "echo hi"), + ... returncode=0, + ... ) + >>> result.ok + True + >>> result.raise_for_status() is result + True + + A failed result raises only when asked: + + >>> failed = SendKeys(target=PaneId("%9"), keys="x").build_result( + ... argv=("send-keys", "-t", "%9", "x"), + ... returncode=1, + ... stderr=("can't find pane %9",), + ... ) + >>> failed.ok + False + >>> failed.raise_for_status() + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.TmuxCommandError: tmux 'send-keys -t %9 x' + failed (exit 1): can't find pane %9 + """ + + operation: Operation[t.Any] + argv: tuple[str, ...] + status: Status + returncode: int + stdout: tuple[str, ...] = () + stderr: tuple[str, ...] = () + + @property + def ok(self) -> bool: + """Whether the operation completed successfully.""" + return self.status == "complete" + + @property + def failed(self) -> bool: + """Whether the operation ran and tmux reported failure.""" + return self.status == "failed" + + @property + def created_id(self) -> str | None: + """The id of an object this operation created, if any (else ``None``). + + Result subclasses for creation ops override this; a lazy plan reads it to + bind a forward :class:`~._types.SlotRef`. The base result creates nothing. + """ + return None + + @property + def created_subids(self) -> Mapping[str, str]: + """Ids of implicit children this op created (e.g. a window's first pane). + + Keyed by part (``"window"`` / ``"pane"``); empty by default. A lazy plan + binds these so a :class:`~._types.SlotRef` sub-reference (``slot.pane`` / + ``slot.window``) can target an object created as a side effect. + """ + return {} + + def raise_for_status(self) -> Self: + """Raise :class:`~.exc.TmuxCommandError` if the result is not OK. + + Returns ``self`` on success so it can be used fluently + (``result = run(op, engine).raise_for_status()``). A ``failed`` or + ``unknown`` status raises; ``complete`` and ``skipped`` do not. + + Returns + ------- + Self + + Raises + ------ + ~libtmux.experimental.ops.exc.TmuxCommandError + When :attr:`status` is ``failed`` or ``unknown``. + """ + if self.status in {"failed", "unknown"}: + raise TmuxCommandError( + self.returncode, + self.argv, + self.stdout, + self.stderr, + ) + return self + + +@dataclass(frozen=True) +class AckResult(Result): + """Result of an operation that returns no data -- only success or failure. + + Many tmux commands (``rename-window``, ``kill-pane``, ``select-window``, ...) + print nothing. In the CLI they exit ``0`` on success or write to stderr and + exit nonzero on failure; in control mode tmux frames them as ``%end`` + (success) or ``%error`` (failure) -- it never calls ``cmdq_print`` for them + (see tmux ``cmd-queue.c``). An :class:`AckResult` is the typed + acknowledgement for exactly that case: no payload, but + :meth:`~Result.raise_for_status` still surfaces the error path, because a + no-output command can still fail. + + Examples + -------- + >>> from libtmux.experimental.ops import RenameWindow + >>> from libtmux.experimental.ops._types import WindowId + >>> op = RenameWindow(target=WindowId("@1"), name="build") + >>> ok = op.build_result(returncode=0) + >>> type(ok).__name__, ok.ok + ('AckResult', True) + >>> failed = op.build_result(returncode=1, stderr=("can't find window @1",)) + >>> failed.ok + False + """ + + +@dataclass(frozen=True) +class SplitWindowResult(Result): + """Result of a ``split-window`` operation. + + Adds the id of the pane tmux created, when it was captured. + """ + + new_pane_id: str | None = None + + @property + def created_id(self) -> str | None: + """The new pane's id.""" + return self.new_pane_id + + +@dataclass(frozen=True) +class CreateResult(Result): + """Result of an operation that creates an object and captures its id. + + Shared by ``new-window`` / ``new-session`` (and other ``-P -F``-capturing + creators); :attr:`new_id` holds the created object's id (``@N``/``$N``). + When the creator opts into capturing its implicit children (a session's first + window/pane, a window's first pane), those ids land in + :attr:`first_window_id` / :attr:`first_pane_id` and in :attr:`created_subids`. + """ + + new_id: str | None = None + first_window_id: str | None = None + first_pane_id: str | None = None + + @property + def created_id(self) -> str | None: + """The created object's id.""" + return self.new_id + + @property + def created_subids(self) -> Mapping[str, str]: + """The captured implicit children, keyed by ``"window"`` / ``"pane"``.""" + out: dict[str, str] = {} + if self.first_window_id is not None: + out["window"] = self.first_window_id + if self.first_pane_id is not None: + out["pane"] = self.first_pane_id + return out + + +@dataclass(frozen=True) +class CapturePaneResult(Result): + """Result of a ``capture-pane`` operation. + + Adds the captured pane lines as :attr:`lines` (also available as + :attr:`stdout`). + """ + + lines: tuple[str, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class ListPanesResult(Result): + """Result of a ``list-panes`` operation. + + Stores the parsed per-pane format mappings as JSON-friendly :attr:`rows` + (so the result serializes without snapshot objects), and derives typed + :class:`~..models.snapshots.PaneSnapshot` / :class:`ServerSnapshot` views on + demand. + """ + + rows: tuple[Mapping[str, str], ...] = () + + @property + def panes(self) -> tuple[PaneSnapshot, ...]: + """One typed pane snapshot per row.""" + return tuple(PaneSnapshot.from_format(row) for row in self.rows) + + @property + def server(self) -> ServerSnapshot: + """The full session/window/pane tree built from the rows.""" + return ServerSnapshot.from_pane_rows(self.rows) + + +@dataclass(frozen=True) +class ListWindowsResult(Result): + """Result of a ``list-windows`` operation (typed :attr:`windows`).""" + + rows: tuple[Mapping[str, str], ...] = () + + @property + def windows(self) -> tuple[WindowSnapshot, ...]: + """One typed window snapshot per row.""" + return tuple(WindowSnapshot.from_format(row) for row in self.rows) + + +@dataclass(frozen=True) +class ListSessionsResult(Result): + """Result of a ``list-sessions`` operation (typed :attr:`sessions`).""" + + rows: tuple[Mapping[str, str], ...] = () + + @property + def sessions(self) -> tuple[SessionSnapshot, ...]: + """One typed session snapshot per row.""" + return tuple(SessionSnapshot.from_format(row) for row in self.rows) + + +@dataclass(frozen=True) +class ListClientsResult(Result): + """Result of a ``list-clients`` operation (typed :attr:`clients`).""" + + rows: tuple[Mapping[str, str], ...] = () + + @property + def clients(self) -> tuple[ClientSnapshot, ...]: + """One typed client snapshot per row.""" + return tuple(ClientSnapshot.from_format(row) for row in self.rows) + + +@dataclass(frozen=True) +class HasSessionResult(Result): + """Result of a ``has-session`` existence query. + + ``has-session`` exits ``0`` when the session exists and nonzero otherwise -- + a valid answer, not a failure -- so this result is always ``complete`` and + carries the answer in :attr:`exists`. + """ + + exists: bool = False + + +@dataclass(frozen=True) +class DisplayMessageResult(Result): + """Result of ``display-message -p``: the formatted :attr:`text`.""" + + text: str = "" + + +@dataclass(frozen=True) +class ShowOptionsResult(Result): + """Result of ``show-options``: parsed ``name value`` pairs in :attr:`options`.""" + + options: Mapping[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ShowBufferResult(Result): + """Result of ``show-buffer``: the buffer contents as :attr:`text`.""" + + text: str = "" diff --git a/src/libtmux/experimental/ops/serialize.py b/src/libtmux/experimental/ops/serialize.py new file mode 100644 index 000000000..bfbf4066e --- /dev/null +++ b/src/libtmux/experimental/ops/serialize.py @@ -0,0 +1,217 @@ +"""Serialize operations and results to/from plain dicts. + +Serialized payloads contain only stable, JSON-friendly data -- a ``kind`` +discriminator, target descriptors, scalar fields, and captured output. They hold +no live :class:`~libtmux.Server`/:class:`~libtmux.Pane`, subprocess handles, or +event-loop objects, so an operation built in one process can be reconstructed in +another. Reconstruction goes through the registry, so only registered operations +can be revived (fail closed). +""" + +from __future__ import annotations + +import dataclasses +import typing as t + +from libtmux.experimental.ops._types import ( + ClientName, + IndexRef, + NameRef, + PaneId, + SessionId, + SlotRef, + Special, + WindowId, +) +from libtmux.experimental.ops.registry import registry + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + +_TARGET_TYPES: dict[str, type] = { + cls.__name__: cls + for cls in ( + PaneId, + WindowId, + SessionId, + ClientName, + NameRef, + IndexRef, + Special, + SlotRef, + ) +} + + +def target_to_dict(target: Target | None) -> dict[str, t.Any] | None: + """Serialize a :data:`~._types.Target` to a tagged dict (or ``None``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> target_to_dict(PaneId("%1")) + {'type': 'PaneId', 'value': '%1'} + >>> target_to_dict(None) is None + True + """ + if target is None: + return None + return {"type": type(target).__name__, **dataclasses.asdict(target)} + + +def target_from_dict(data: Mapping[str, t.Any] | None) -> Target | None: + """Reconstruct a :data:`~._types.Target` from :func:`target_to_dict` output. + + Examples + -------- + >>> target_from_dict({"type": "PaneId", "value": "%1"}) + PaneId(value='%1') + >>> target_from_dict(None) is None + True + """ + if data is None: + return None + cls = _TARGET_TYPES[data["type"]] + fields = {key: value for key, value in data.items() if key != "type"} + return t.cast("Target", cls(**fields)) + + +def _jsonify(value: t.Any) -> t.Any: + """Render a field value as JSON-friendly data.""" + if isinstance(value, tuple): + return list(value) + if isinstance(value, dict): + return dict(value) + return value + + +def operation_to_dict(operation: Operation[t.Any]) -> dict[str, t.Any]: + """Serialize an operation to a plain dict. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> data = operation_to_dict(SplitWindow(target=PaneId("%1"), horizontal=True)) + >>> data["kind"], data["target"], data["horizontal"] + ('split_window', {'type': 'PaneId', 'value': '%1'}, True) + """ + data: dict[str, t.Any] = {"kind": operation.kind} + for field in dataclasses.fields(operation): + value = getattr(operation, field.name) + if field.name in {"target", "src_target"}: + data[field.name] = target_to_dict(value) + else: + data[field.name] = _jsonify(value) + return data + + +def operation_from_dict(data: Mapping[str, t.Any]) -> Operation[t.Any]: + """Reconstruct an operation from :func:`operation_to_dict` output. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> op = SplitWindow(target=PaneId("%1"), horizontal=True) + >>> operation_from_dict(operation_to_dict(op)) == op + True + """ + operation_cls = registry.operation(data["kind"]) + kwargs: dict[str, t.Any] = {} + for field in dataclasses.fields(operation_cls): + if field.name not in data: + continue + if field.name in {"target", "src_target"}: + kwargs[field.name] = target_from_dict(data[field.name]) + else: + kwargs[field.name] = data[field.name] + return operation_cls(**kwargs) + + +def _coerce_field(value: t.Any) -> t.Any: + """Coerce a JSON list back into the tuple a result field expects.""" + if isinstance(value, list): + return tuple(value) + return value + + +def result_to_dict(result: Result) -> dict[str, t.Any]: + """Serialize a result (and its operation) to a plain dict. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> r = SplitWindow(target=PaneId("%1")).build_result(returncode=0, stdout=("%2",)) + >>> data = result_to_dict(r) + >>> data["status"], data["new_pane_id"] + ('complete', '%2') + """ + data: dict[str, t.Any] = {"operation": operation_to_dict(result.operation)} + for field in dataclasses.fields(result): + if field.name == "operation": + continue + data[field.name] = _jsonify(getattr(result, field.name)) + return data + + +def result_from_dict(data: Mapping[str, t.Any]) -> Result: + """Reconstruct a result from :func:`result_to_dict` output. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> r = SplitWindow(target=PaneId("%1")).build_result(returncode=0, stdout=("%2",)) + >>> result_from_dict(result_to_dict(r)) == r + True + """ + operation = operation_from_dict(data["operation"]) + result_cls = type(operation).result_cls + kwargs: dict[str, t.Any] = {"operation": operation} + for field in dataclasses.fields(result_cls): + if field.name == "operation" or field.name not in data: + continue + kwargs[field.name] = _coerce_field(data[field.name]) + return result_cls(**kwargs) + + +def bindings_to_dict(bindings: Mapping[int | tuple[int, str], str]) -> dict[str, str]: + """Serialize plan bindings to a JSON-friendly ``str``-keyed dict. + + A plain slot key ``N`` becomes ``"N"``; a sub-ref key ``(N, part)`` becomes + ``"N:part"`` (e.g. ``(0, "pane")`` -> ``"0:pane"``) so a forward-ref binding + survives a JSON round-trip. + + Examples + -------- + >>> bindings_to_dict({0: "$1", (0, "pane"): "%2"}) + {'0': '$1', '0:pane': '%2'} + """ + out: dict[str, str] = {} + for key, value in bindings.items(): + out[f"{key[0]}:{key[1]}" if isinstance(key, tuple) else str(key)] = value + return out + + +def bindings_from_dict(data: Mapping[str, str]) -> dict[int | tuple[int, str], str]: + """Reconstruct plan bindings from :func:`bindings_to_dict` output. + + Examples + -------- + >>> bindings_from_dict({"0": "$1", "0:pane": "%2"}) == {0: "$1", (0, "pane"): "%2"} + True + """ + out: dict[int | tuple[int, str], str] = {} + for key, value in data.items(): + if ":" in key: + slot, part = key.split(":", 1) + out[int(slot), part] = value + else: + out[int(key)] = value + return out diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py new file mode 100644 index 000000000..9b1f34bfe --- /dev/null +++ b/src/libtmux/experimental/workspace/__init__.py @@ -0,0 +1,51 @@ +"""Declarative WorkspaceBuilder: a structural object language over the Core ops. + +The *Declarative* tier (à la SQLAlchemy Declarative on Core). Declare a workspace +shape with :class:`~.ir.Workspace` / :class:`~.ir.Window` / :class:`~.ir.Pane`; +:func:`~.analyzer.analyze` builds that tree from a tmuxp-style YAML/dict; the +compiler lowers it to a Core :class:`~libtmux.experimental.ops.plan.LazyPlan`; the +runner executes it over any engine, sync or async; :func:`~.confirm.confirm` +verifies the live result. + +Everything here is experimental and outside the versioning policy. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> ws = analyze({ +... "session_name": "dev", +... "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], +... }) +>>> ws.build(ConcreteEngine(), preflight=False).ok +True +""" + +from __future__ import annotations + +from libtmux.experimental.workspace.analyzer import analyze +from libtmux.experimental.workspace.compiler import ( + Compiled, + HostStep, + WorkspaceCompileError, + compile_full, + compile_workspace, +) +from libtmux.experimental.workspace.confirm import ConfirmReport, confirm +from libtmux.experimental.workspace.ir import Pane, Window, Workspace +from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace + +__all__ = ( + "Compiled", + "ConfirmReport", + "HostStep", + "Pane", + "Window", + "Workspace", + "WorkspaceCompileError", + "abuild_workspace", + "analyze", + "build_workspace", + "compile_full", + "compile_workspace", + "confirm", +) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py new file mode 100644 index 000000000..55118204f --- /dev/null +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -0,0 +1,120 @@ +"""Analyze a tmuxp-style YAML/dict workspace into the declarative IR. + +A small subset of tmuxp's ``loader.expand``/``trickle``: it normalizes shorthand +(a bare-string pane, a string/list ``shell_command``, a ``cmd``-dict list) into the +canonical :class:`~.ir.Workspace` / :class:`~.ir.Window` / :class:`~.ir.Pane` tree. +Pure -- no tmux. + +Examples +-------- +>>> ws = analyze({ +... "session_name": "dev", +... "start_directory": "~/work", +... "windows": [ +... {"window_name": "editor", "layout": "main-vertical", +... "panes": ["vim", {"shell_command": ["cd src", "pytest -q"]}]}, +... {"window_name": "logs", "panes": ["tail -f app.log"]}, +... ], +... }) +>>> ws.name +'dev' +>>> [w.name for w in ws.windows] +['editor', 'logs'] +>>> ws.windows[0].panes[0].commands +('vim',) +>>> ws.windows[0].panes[1].commands +('cd src', 'pytest -q') +""" + +from __future__ import annotations + +import collections.abc +import typing as t + +from libtmux.experimental.workspace.ir import Pane, Window, Workspace + + +def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: + """Normalize a tmuxp-style config (dict or YAML string) into a Workspace.""" + data = _load(raw) + windows = [_window(w) for w in data.get("windows", []) or []] + return Workspace( + name=data["session_name"], + dimensions=_dimensions(data.get("dimensions")), + start_directory=data.get("start_directory"), + environment=dict(data.get("environment", {}) or {}), + options=dict(data.get("options", {}) or {}), + windows=windows, + before_script=data.get("before_script"), + on_exists=data.get("on_exists", "error"), + ) + + +def _load( + raw: collections.abc.Mapping[str, t.Any] | str, +) -> collections.abc.Mapping[str, t.Any]: + """Return a mapping from a dict or a YAML string.""" + if isinstance(raw, str): + import yaml # type: ignore[import-untyped] + + loaded = yaml.safe_load(raw) + if not isinstance(loaded, collections.abc.Mapping): + msg = "workspace YAML must be a mapping" + raise TypeError(msg) + return loaded + return raw + + +def _dimensions(value: t.Any) -> tuple[int, int] | None: + """Coerce a ``[x, y]`` / ``{width, height}`` value to a dimensions tuple.""" + if value is None: + return None + if isinstance(value, collections.abc.Mapping): + return (int(value["width"]), int(value["height"])) + width, height = value + return (int(width), int(height)) + + +def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: + """Normalize one window config.""" + return Window( + name=raw.get("window_name"), + layout=raw.get("layout"), + start_directory=raw.get("start_directory"), + focus=bool(raw.get("focus", False)), + options=dict(raw.get("options", {}) or {}), + panes=[_pane(p) for p in raw.get("panes", []) or []], + ) + + +def _pane(raw: t.Any) -> Pane: + """Normalize one pane config (None / bare string / mapping).""" + if raw is None: + return Pane() + if isinstance(raw, str): + return Pane(run=raw) + if isinstance(raw, collections.abc.Mapping): + return Pane( + run=_shell_commands(raw.get("shell_command")), + focus=bool(raw.get("focus", False)), + start_directory=raw.get("start_directory"), + sleep_before=raw.get("sleep_before"), + sleep_after=raw.get("sleep_after"), + ) + msg = f"unsupported pane config: {raw!r}" + raise TypeError(msg) + + +def _shell_commands(value: t.Any) -> tuple[str, ...]: + """Normalize a ``shell_command`` (None / string / list of str|{cmd}).""" + if value is None: + return () + if isinstance(value, str): + return (value,) + out: list[str] = [] + for item in t.cast("collections.abc.Sequence[t.Any]", value): + if isinstance(item, str): + out.append(item) + elif isinstance(item, collections.abc.Mapping): + out.append(str(item["cmd"])) + return tuple(out) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py new file mode 100644 index 000000000..8dd9e3efc --- /dev/null +++ b/src/libtmux/experimental/workspace/compiler.py @@ -0,0 +1,236 @@ +"""Compile a declarative :class:`~.ir.Workspace` into a Core ``LazyPlan``. + +This is the *unit-of-work* of the Declarative tier: it walks the structural spec +tree and emits Core operations in tmuxp-faithful order (create session -> per +window: create/rename, window options, reuse the first pane, split the rest, send +keys, apply layout, focus panes; then focus the window last), wiring +:class:`~libtmux.experimental.ops._types.SlotRef` forward references so the caller +never handles a tmux id. + +Implicit-object strategy: creators opt into capturing their implicit children's +ids (``NewSession(capture_panes=True)`` -> session/first-window/first-pane; +``NewWindow(capture_pane=True)`` -> window/first-pane), so every window's first +pane has a real captured id reachable as ``slot.pane`` / ``session.pane``. The +session's first window is reused as window 1 (addressed via ``session.window`` / +``session.pane``); windows 2..N are created detached. Because the first pane has a +concrete id, first-pane focus and any-order sends work, and the +``compile() -> LazyPlan`` stays executable by Core (the sub-ids bind in +``_drive``). + +Host-side steps (sleep / before_script) are returned alongside the plan in a +:class:`Compiled` schedule -- they are *not* recorded as operations, keeping the +Core op spine pure. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops import ( + LazyPlan, + NewSession, + NewWindow, + RenameWindow, + SelectLayout, + SelectPane, + SelectWindow, + SendKeys, + SetEnvironment, + SetOption, + SetWindowOption, + SplitWindow, +) +from libtmux.experimental.workspace.ir import Pane + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import SlotRef + from libtmux.experimental.workspace.ir import Window, Workspace + + +class WorkspaceCompileError(ValueError): + """A declared workspace cannot be lowered to Core operations.""" + + +@dataclass(frozen=True) +class HostStep: + """A host-side step interleaved by the runner (not a tmux operation).""" + + kind: t.Literal["sleep", "script"] + seconds: float | None = None + command: str | None = None + cwd: str | None = None + + +@dataclass(frozen=True) +class Compiled: + """A compiled workspace: the Core plan plus its host-step schedule. + + Parameters + ---------- + plan : LazyPlan + The pure Core operations (executable by any engine via ``execute``). + host_after : Mapping[int, tuple[HostStep, ...]] + Host steps to run *after* the operation at the given index. + pre : tuple[HostStep, ...] + Host steps to run before any operation (e.g. ``before_script``). + """ + + plan: LazyPlan + host_after: Mapping[int, tuple[HostStep, ...]] = field(default_factory=dict) + pre: tuple[HostStep, ...] = () + + +def compile_workspace(ws: Workspace, *, version: str | None = None) -> LazyPlan: + """Lower a declarative workspace into a Core ``LazyPlan`` (ops only). + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane + >>> ws = Workspace(name="dev", windows=[Window("editor", panes=[Pane(run="vim")])]) + >>> [op.kind for op in compile_workspace(ws).operations] + ['new_session', 'rename_window', 'send_keys'] + """ + return compile_full(ws, version=version).plan + + +def _schedule_before( + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + next_index: int, + step: HostStep, +) -> None: + """Schedule *step* to run just before the op that will land at *next_index*.""" + after = next_index - 1 + if after < 0: + pre.append(step) + else: + host_after.setdefault(after, []).append(step) + + +def _emit_window( + plan: LazyPlan, + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + ws: Workspace, + window: Window, + window_ref: SlotRef, + first_pane_ref: SlotRef, +) -> None: + """Emit a window's options, panes, sends, layout, and pane focus. + + *window_ref* addresses the window (rename/options/layout); *first_pane_ref* is + the captured id of the window's first pane. + """ + for key, value in window.options.items(): + plan.add(SetWindowOption(target=window_ref, option=key, value=value)) + + panes = list(window.panes) or [Pane()] + prev: SlotRef = first_pane_ref + focus_targets: list[SlotRef] = [] + for pane_index, pane in enumerate(panes): + if pane_index == 0: + target: SlotRef = first_pane_ref + else: + target = plan.add( + SplitWindow( + target=prev, + start_directory=( + pane.start_directory + or window.start_directory + or ws.start_directory + ), + ), + ) + if pane.commands: + if pane.sleep_before is not None: + _schedule_before( + host_after, + pre, + len(plan), + HostStep("sleep", seconds=pane.sleep_before), + ) + for command in pane.commands: + plan.add( + SendKeys( + target=target, + keys=command, + enter=True, + suppress_history=pane.suppress_history, + ), + ) + if pane.sleep_after is not None: + host_after.setdefault(len(plan) - 1, []).append( + HostStep("sleep", seconds=pane.sleep_after), + ) + if pane.focus: + focus_targets.append(target) + prev = target + + if window.layout is not None: + plan.add(SelectLayout(target=window_ref, layout=window.layout)) + for target in focus_targets: + plan.add(SelectPane(target=target)) + + +def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: + """Lower a workspace into a Core plan plus its host-step schedule.""" + if not ws.windows: + msg = f"workspace {ws.name!r} declares no windows" + raise WorkspaceCompileError(msg) + + plan = LazyPlan() + host_after: dict[int, list[HostStep]] = {} + pre: list[HostStep] = [] + if ws.before_script: + pre.append(HostStep("script", command=ws.before_script, cwd=ws.start_directory)) + + width = ws.dimensions[0] if ws.dimensions else None + height = ws.dimensions[1] if ws.dimensions else None + session = plan.add( + NewSession( + session_name=ws.name, + start_directory=ws.start_directory, + width=width, + height=height, + capture_panes=True, + ), + ) + for key, value in ws.environment.items(): + plan.add(SetEnvironment(target=session, name=key, value=value)) + for key, value in ws.options.items(): + plan.add(SetOption(target=session, option=key, value=value)) + + window_refs: list[SlotRef] = [] + for index, window in enumerate(ws.windows): + if index == 0: + # Reuse the session's implicit first window via its captured ids. + window_ref: SlotRef = session.window + first_pane_ref = session.pane + if window.name is not None: + plan.add(RenameWindow(target=window_ref, name=window.name)) + else: + slot = plan.add( + NewWindow( + target=session, + name=window.name, + start_directory=window.start_directory or ws.start_directory, + capture_pane=True, + ), + ) + window_ref = slot + first_pane_ref = slot.pane + window_refs.append(window_ref) + _emit_window(plan, host_after, pre, ws, window, window_ref, first_pane_ref) + + for index, window in enumerate(ws.windows): + if window.focus: + plan.add(SelectWindow(target=window_refs[index])) + + return Compiled( + plan, + {key: tuple(value) for key, value in host_after.items()}, + tuple(pre), + ) diff --git a/src/libtmux/experimental/workspace/confirm.py b/src/libtmux/experimental/workspace/confirm.py new file mode 100644 index 000000000..020f2e992 --- /dev/null +++ b/src/libtmux/experimental/workspace/confirm.py @@ -0,0 +1,88 @@ +"""Confirm a built workspace matches its declarative spec (live introspection). + +Reads the live server through the classic libtmux objects and diffs the observed +session/window/pane structure against the declared :class:`~.ir.Workspace`. Used by +the live test track; the offline (``ConcreteEngine``) track asserts on the +compiled plan instead, since a stateless engine has no structure to read back. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from libtmux.experimental.workspace.ir import Workspace + from libtmux.server import Server + + +@dataclass +class ConfirmReport: + """The outcome of confirming a built workspace against its spec.""" + + ok: bool + problems: tuple[str, ...] + + +def confirm(ws: Workspace, server: Server, *, timeout: float = 5.0) -> ConfirmReport: + """Diff the live server against the declared workspace; report mismatches.""" + problems: list[str] = [] + sessions = server.sessions.filter(session_name=ws.name) + if not sessions: + return ConfirmReport(ok=False, problems=(f"session {ws.name!r} not found",)) + session = sessions[0] + + windows = list(session.windows) + if len(windows) != len(ws.windows): + problems.append(f"window count {len(windows)} != declared {len(ws.windows)}") + + for spec, live in zip(ws.windows, windows, strict=False): + if spec.name is not None and live.window_name != spec.name: + problems.append( + f"window name {live.window_name!r} != declared {spec.name!r}" + ) + live_panes = list(live.panes) + expected_panes = max(1, len(spec.panes)) + if len(live_panes) != expected_panes: + problems.append( + f"window {spec.name!r} pane count " + f"{len(live_panes)} != declared {expected_panes}", + ) + focused_panes = [i for i, p in enumerate(spec.panes) if p.focus] + if focused_panes and focused_panes[-1] < len(live_panes): + want_idx = focused_panes[-1] + active_pane = live.active_pane + if active_pane is None or ( + active_pane.pane_id != live_panes[want_idx].pane_id + ): + problems.append( + f"window {spec.name!r} active pane != declared focus " + f"(pane index {want_idx})", + ) + + focused = [w for w in ws.windows if w.focus] + if focused and focused[-1].name is not None: + want = focused[-1].name + active_name = session.active_window.window_name + if active_name != want: + problems.append( + f"active window {active_name!r} != declared focus {want!r}", + ) + + if ws.start_directory and windows: + want_cwd = ws.start_directory + session_id = session.session_id + + def _cwd_ok() -> bool: + fresh = server.sessions.filter(session_id=session_id) + if not fresh: + return False + pane = next(iter(fresh[0].windows)).active_pane + return pane is not None and pane.pane_current_path == want_cwd + + if not retry_until(_cwd_ok, timeout, raises=False): + problems.append(f"first pane cwd != declared {want_cwd!r}") + + return ConfirmReport(ok=not problems, problems=tuple(problems)) diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py new file mode 100644 index 000000000..85e34edca --- /dev/null +++ b/src/libtmux/experimental/workspace/ir.py @@ -0,0 +1,185 @@ +"""Declarative workspace specs -- the structural object language. + +The *Declarative* tier (à la SQLAlchemy Declarative on Core): the user declares +the **shape** of a workspace as a tree of :class:`Workspace` / :class:`Window` / +:class:`Pane` values, and the compiler lowers that tree into a Core +:class:`~libtmux.experimental.ops.plan.LazyPlan`. The specs are pure, immutable +data -- no tmux, no engine -- so they round-trip to/from YAML and can be inspected +before anything runs. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane +>>> ws = Workspace( +... name="dev", +... windows=[ +... Window("editor", layout="main-vertical", panes=[ +... Pane(run="vim"), +... Pane(run="pytest -q", focus=True), +... ]), +... Window("logs", panes=[Pane(run="tail -f app.log")]), +... ], +... ) +>>> ws.compile().operations[0].kind +'new_session' +>>> ws.build(ConcreteEngine(), preflight=False).ok +True +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.plan import LazyPlan, PlanResult + + +@dataclass(frozen=True) +class Pane: + """A pane in the declared workspace. + + Parameters + ---------- + run : str or Sequence[str] or None + Command(s) to send after the pane is created (a bare string is one + command). + focus : bool + Select this pane once its window's panes are built. Focusing the *first* + pane of a multi-pane window is rejected at compile time (the implicit + first pane has no captured id after the window is split). + start_directory : str or None + Working directory (inherited from window/session when unset). + suppress_history : bool + Keep sent commands out of shell history (leading-space trick). + sleep_before, sleep_after : float or None + Host-side delays around this pane's commands (orchestration, not tmux). + """ + + run: str | Sequence[str] | None = None + focus: bool = False + start_directory: str | None = None + suppress_history: bool = True + sleep_before: float | None = None + sleep_after: float | None = None + + @property + def commands(self) -> tuple[str, ...]: + """The pane's commands as a tuple (a bare string becomes one command).""" + if self.run is None: + return () + if isinstance(self.run, str): + return (self.run,) + return tuple(self.run) + + +@dataclass(frozen=True) +class Window: + """A window in the declared workspace. + + Parameters + ---------- + name : str or None + Window name. + layout : str or None + A tmux layout applied after the panes exist (e.g. ``main-vertical``). + start_directory : str or None + Working directory for the window's panes. + focus : bool + Select this window at the end of the build. + options : Mapping[str, str] + ``set-window-option`` key/values. + panes : Sequence[Pane] + The window's panes (the first reuses the window's implicit pane). + """ + + name: str | None = None + layout: str | None = None + start_directory: str | None = None + focus: bool = False + options: Mapping[str, str] = field(default_factory=dict) + panes: Sequence[Pane] = () + + +@dataclass(frozen=True) +class Workspace: + """A declared workspace: a session shape that compiles to Core operations. + + Parameters + ---------- + name : str + Session name. + dimensions : tuple[int, int] or None + ``(width, height)`` for the session (``-x``/``-y``). + start_directory : str or None + Working directory for the session. + environment : Mapping[str, str] + ``set-environment`` key/values. + options : Mapping[str, str] + ``set-option`` (session) key/values. + windows : Sequence[Window] + The session's windows. + before_script : str or None + A host shell command run once before building (orchestration). + on_exists : {"error", "replace", "reuse"} + What to do if a session of this name already exists. + """ + + name: str + dimensions: tuple[int, int] | None = None + start_directory: str | None = None + environment: Mapping[str, str] = field(default_factory=dict) + options: Mapping[str, str] = field(default_factory=dict) + windows: Sequence[Window] = () + before_script: str | None = None + on_exists: t.Literal["error", "replace", "reuse"] = "error" + + def compile(self, *, version: str | None = None) -> LazyPlan: + """Lower this declared workspace into a Core ``LazyPlan`` (ops only). + + The returned plan is the escape hatch to the Core tier: inspect it, + serialize it, or execute it directly with any engine. Host steps + (sleep/before_script) and idempotent replace are applied by + :meth:`build`/:meth:`abuild`, not recorded in the plan. + """ + from libtmux.experimental.workspace.compiler import compile_workspace + + return compile_workspace(self, version=version) + + def build( + self, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + ) -> PlanResult: + """Compile and execute this workspace synchronously over *engine*. + + Set ``preflight=False`` to skip the ``on_exists`` ``has-session`` check + (e.g. against the stateless ``ConcreteEngine``, which has no real + sessions to detect). + """ + from libtmux.experimental.workspace.runner import build_workspace + + return build_workspace(self, engine, version=version, preflight=preflight) + + async def abuild( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + ) -> PlanResult: + """Compile and execute this workspace asynchronously over *engine*.""" + from libtmux.experimental.workspace.runner import abuild_workspace + + return await abuild_workspace( + self, + engine, + version=version, + preflight=preflight, + ) diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py new file mode 100644 index 000000000..e99e4aac3 --- /dev/null +++ b/src/libtmux/experimental/workspace/runner.py @@ -0,0 +1,146 @@ +"""Execute a compiled workspace over any engine, sync or async. + +The runner is the Declarative tier's *bound* layer. It keeps the Core operation +spine pure: it drives the compiled plan one operation at a time (reusing Core's +:func:`~libtmux.experimental.ops.plan._resolve` forward-ref resolution) and +interleaves host-side steps (sleep / before_script) *between* operations rather +than weaving them into Core's ``_drive`` generator. Idempotent replace is handled +*around* the build via a ``has-session`` pre-check. + +The same compiled plan runs identically through any engine and through either the +sync (:func:`build_workspace`) or async (:func:`abuild_workspace`) driver -- the +only difference is ``run`` vs ``await arun`` and the host-step executor. + +``preflight=False`` skips the ``on_exists`` ``has-session`` check; use it offline +against the stateless ``ConcreteEngine`` (whose ``has-session`` is always true). +""" + +from __future__ import annotations + +import asyncio +import subprocess +import time +import typing as t + +from libtmux.experimental.ops import HasSession, KillSession, arun, run +from libtmux.experimental.ops._types import NameRef +from libtmux.experimental.ops.plan import PlanResult, _resolve +from libtmux.experimental.workspace.compiler import compile_full + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.results import Result + from libtmux.experimental.workspace.compiler import HostStep + from libtmux.experimental.workspace.ir import Workspace + + +def _run_host_sync(step: HostStep) -> None: + """Execute one host step synchronously.""" + if step.kind == "sleep" and step.seconds is not None: + time.sleep(step.seconds) + elif step.kind == "script" and step.command is not None: + subprocess.run(step.command, shell=True, cwd=step.cwd, check=False) + + +async def _run_host_async(step: HostStep) -> None: + """Execute one host step asynchronously.""" + if step.kind == "sleep" and step.seconds is not None: + await asyncio.sleep(step.seconds) + elif step.kind == "script" and step.command is not None: + proc = await asyncio.create_subprocess_shell(step.command, cwd=step.cwd) + await proc.wait() + + +def _preflight_sync(ws: Workspace, engine: TmuxEngine, version: str | None) -> bool: + """Apply the ``on_exists`` policy; return ``True`` if the build should skip.""" + exists = run(HasSession(target=NameRef(ws.name)), engine, version=version).exists + if not exists: + return False + if ws.on_exists == "replace": + run(KillSession(target=NameRef(ws.name)), engine, version=version) + return False + if ws.on_exists == "reuse": + return True + msg = f"session {ws.name!r} already exists (on_exists='error')" + raise FileExistsError(msg) + + +async def _preflight_async( + ws: Workspace, + engine: AsyncTmuxEngine, + version: str | None, +) -> bool: + """Async sibling of :func:`_preflight_sync`.""" + result = await arun(HasSession(target=NameRef(ws.name)), engine, version=version) + if not result.exists: + return False + if ws.on_exists == "replace": + await arun(KillSession(target=NameRef(ws.name)), engine, version=version) + return False + if ws.on_exists == "reuse": + return True + msg = f"session {ws.name!r} already exists (on_exists='error')" + raise FileExistsError(msg) + + +def build_workspace( + ws: Workspace, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanResult: + """Compile and execute *ws* synchronously over *engine*. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane + >>> ws = Workspace(name="dev", windows=[Window("w", panes=[Pane(run="vim")])]) + >>> build_workspace(ws, ConcreteEngine(), preflight=False).ok + True + """ + if preflight and _preflight_sync(ws, engine, version): + return PlanResult((), {}) + compiled = compile_full(ws, version=version) + for step in compiled.pre: + _run_host_sync(step) + bindings: dict[int | tuple[int, str], str] = {} + results: list[Result] = [] + for index, op in enumerate(compiled.plan.operations): + result = run(_resolve(op, bindings), engine, version=version) + results.append(result) + if result.created_id is not None: + bindings[index] = result.created_id + for part, sub in result.created_subids.items(): + bindings[index, part] = sub + for step in compiled.host_after.get(index, ()): + _run_host_sync(step) + return PlanResult(tuple(results), bindings) + + +async def abuild_workspace( + ws: Workspace, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanResult: + """Compile and execute *ws* asynchronously over *engine* (same resolution).""" + if preflight and await _preflight_async(ws, engine, version): + return PlanResult((), {}) + compiled = compile_full(ws, version=version) + for step in compiled.pre: + await _run_host_async(step) + bindings: dict[int | tuple[int, str], str] = {} + results: list[Result] = [] + for index, op in enumerate(compiled.plan.operations): + result = await arun(_resolve(op, bindings), engine, version=version) + results.append(result) + if result.created_id is not None: + bindings[index] = result.created_id + for part, sub in result.created_subids.items(): + bindings[index, part] = sub + for step in compiled.host_after.get(index, ()): + await _run_host_async(step) + return PlanResult(tuple(results), bindings) diff --git a/tests/experimental/__init__.py b/tests/experimental/__init__.py new file mode 100644 index 000000000..a65199c59 --- /dev/null +++ b/tests/experimental/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.""" + +from __future__ import annotations diff --git a/tests/experimental/contract/__init__.py b/tests/experimental/contract/__init__.py new file mode 100644 index 000000000..5b55edb29 --- /dev/null +++ b/tests/experimental/contract/__init__.py @@ -0,0 +1,3 @@ +"""Cross-engine contract tests.""" + +from __future__ import annotations diff --git a/tests/experimental/contract/test_async_control_engine.py b/tests/experimental/contract/test_async_control_engine.py new file mode 100644 index 000000000..bcdc24580 --- /dev/null +++ b/tests/experimental/contract/test_async_control_engine.py @@ -0,0 +1,212 @@ +"""Async control-mode engine against a real tmux server. + +Drives the persistent async ``tmux -C`` engine end to end via :func:`asyncio.run` +and asserts it returns the same typed result the other engines do, plus that its +notification stream works. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines import ( + AsyncConcreteEngine, + AsyncControlModeEngine, + CommandRequest, + ControlNotification, +) +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + RenameWindow, + SplitWindow, + arun, +) +from libtmux.experimental.ops._types import WindowId +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from libtmux.experimental.engines import CommandResult + from libtmux.experimental.ops.plan import PlanResult + from libtmux.experimental.ops.results import AckResult + from libtmux.server import Server + from libtmux.session import Session + + +def test_notification_parse() -> None: + """A raw notification line parses into a typed notification (no tmux).""" + notif = ControlNotification.parse(b"%window-add @3") + assert notif.kind == "window-add" + assert notif.args == ("@3",) + + +def test_notification_parse_output_keeps_payload() -> None: + """An ``%output`` line keeps the pane id and the whole payload as args.""" + notif = ControlNotification.parse(b"%output %1 hello world") + assert notif.kind == "output" + assert notif.args == ("%1", "hello", "world") + assert notif.raw == "%output %1 hello world" + + +def test_notification_parse_line_without_percent() -> None: + """A line lacking the ``%`` prefix still parses to a kind and args.""" + notif = ControlNotification.parse(b"window-renamed @1 new") + assert notif.kind == "window-renamed" + assert notif.args == ("@1", "new") + + +def test_async_control_split_creates_real_pane(session: Session) -> None: + """An async control-mode split returns a typed result; the pane exists.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + + async def main() -> SplitWindowResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await arun(SplitWindow(target=WindowId(window_id)), engine) + + result = asyncio.run(main()) + assert result.ok + assert result.new_pane_id is not None + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_async_control_batches_pipelined(session: Session) -> None: + """run_batch pipelines several splits over one connection, one result each.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + + async def main() -> tuple[str | None, str | None]: + async with AsyncControlModeEngine.for_server(server) as engine: + r1 = await arun(SplitWindow(target=WindowId(window_id)), engine) + r2 = await arun(SplitWindow(target=WindowId(window_id)), engine) + return r1.new_pane_id, r2.new_pane_id + + first, second = asyncio.run(main()) + assert first is not None + assert second is not None + assert first != second + + +def test_async_control_concrete_parity(session: Session) -> None: + """Async control-mode and concrete engines agree on result type and argv.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + operation = SplitWindow(target=WindowId(window_id)) + + async def main() -> SplitWindowResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await arun(operation, engine) + + control = asyncio.run(main()) + concrete = asyncio.run(arun(operation, AsyncConcreteEngine())) + assert type(control) is type(concrete) is SplitWindowResult + assert control.argv == concrete.argv == operation.render() + + +def test_async_control_event_stream(session: Session) -> None: + """A command that changes server state surfaces a notification on the stream.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + + async def main() -> ControlNotification: + async with AsyncControlModeEngine.for_server(server) as engine: + events = engine.subscribe() + await arun(SplitWindow(target=WindowId(window_id)), engine) + return await asyncio.wait_for(anext(events), timeout=10.0) + + notif = asyncio.run(main()) + assert notif.kind + assert notif.raw.startswith("%") + + +def test_async_control_empty_batch_short_circuits() -> None: + """``run_batch([])`` returns ``[]`` without ever spawning a tmux process.""" + engine = AsyncControlModeEngine() + assert asyncio.run(engine.run_batch([])) == [] + + +def test_async_control_aclose_without_start_is_safe() -> None: + """Closing an engine that was never started is a no-op, not an error.""" + engine = AsyncControlModeEngine() + asyncio.run(engine.aclose()) + assert engine.dropped_notifications == 0 + + +def test_async_control_for_server_carries_socket(server: Server) -> None: + """``for_server`` threads the live server's socket into the connection flags.""" + engine = AsyncControlModeEngine.for_server(server) + assert any(arg.startswith(("-L", "-S")) for arg in engine.server_args) + assert engine.tmux_bin == server.tmux_bin + + +def test_async_control_run_batch_pipelines_one_call(session: Session) -> None: + """One ``run_batch`` call dispatches several requests, one result each, in order.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + request = CommandRequest.from_args( + *SplitWindow(target=WindowId(window_id)).render() + ) + + async def main() -> list[CommandResult]: + async with AsyncControlModeEngine.for_server(server) as engine: + return await engine.run_batch([request, request]) + + results = asyncio.run(main()) + assert len(results) == 2 + assert all(result.returncode == 0 for result in results) + # Each split captured a distinct new pane id on its own block. + assert results[0].stdout and results[1].stdout + assert results[0].stdout[0] != results[1].stdout[0] + + +def test_async_control_folds_chain_over_one_dispatch(session: Session) -> None: + """A folded ``;`` chain dispatches as one multi-block command; each op completes. + + The other tests dispatch only single-command operations, so the reader's + "wait for ``expected`` blocks" correlation (``command_count`` > 1) is never + exercised. A ``FoldingPlanner`` chain of two renames sends one ``a ; b`` line + that tmux answers with two blocks, proving block accumulation and per-op + attribution over the async connection. + """ + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + plan = LazyPlan() + plan.add_chain( + RenameWindow(target=WindowId(window_id), name="first") + >> RenameWindow(target=WindowId(window_id), name="folded"), + ) + + async def main() -> PlanResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await plan.aexecute(engine, planner=FoldingPlanner()) + + outcome = asyncio.run(main()) + assert outcome.ok + assert [result.status for result in outcome.results] == ["complete", "complete"] + # The last rename in the folded line won, proving both sub-commands ran. + renamed = server.windows.get(window_id=window_id) + assert renamed is not None + assert renamed.window_name == "folded" + + +def test_async_control_failure_is_data_not_raised(session: Session) -> None: + """A tmux-rejected command yields a failed result; the engine does not raise.""" + server = session.server + + async def main() -> AckResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await arun( + RenameWindow(target=WindowId("@999999"), name="nope"), + engine, + ) + + result = asyncio.run(main()) + assert result.ok is False + assert result.returncode != 0 diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py new file mode 100644 index 000000000..826bb4033 --- /dev/null +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -0,0 +1,624 @@ +"""Declarative WorkspaceBuilder over the typed-ops Core, on real tmux. + +Replicates a tmuxp-style workspace build through the Declarative tier +(:mod:`libtmux.experimental.workspace`): a YAML/dict is analyzed into a structural +``Workspace`` spec, compiled to a Core ``LazyPlan``, and executed. Two tracks: + +* **offline** -- compile against the in-memory ``ConcreteEngine`` and assert the + op sequence and planner-equivalence (no tmux); +* **live** -- build over the async control-mode engine *and* the sync subprocess + engine against a real tmux server, then confirm the live structure matches the + spec. The same spec drives every engine and both sync and async. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ( + AsyncControlModeEngine, + ConcreteEngine, + SubprocessEngine, +) +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + MarkedPlanner, + NewSession, + SequentialPlanner, + SetEnvironment, + SetOption, + SetWindowOption, +) +from libtmux.experimental.workspace import ( + HostStep, + Pane, + Window, + Workspace, + WorkspaceCompileError, + analyze, + compile_full, + confirm, +) +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from pathlib import Path + + from libtmux.experimental.ops.plan import PlanResult + from libtmux.session import Session + +_YAML = """ +session_name: ws-offline +start_directory: /tmp +windows: + - window_name: editor + layout: main-vertical + panes: + - echo top + - shell_command: + - echo bottom-1 + - echo bottom-2 + focus: true + - window_name: logs + focus: true + panes: + - echo logging +""" + + +def _spec(start_directory: str, name: str = "ws-live") -> Workspace: + """Return a two-window workspace spec rooted at *start_directory*.""" + return analyze( + { + "session_name": name, + "start_directory": start_directory, + "on_exists": "replace", + "windows": [ + { + "window_name": "editor", + "layout": "main-vertical", + "panes": [ + # First pane focused in a multi-pane window -- the case the + # spike broke; first-pane-id capture makes it work. + {"shell_command": ["echo top"], "focus": True}, + "echo bottom", + ], + }, + {"window_name": "logs", "focus": True, "panes": ["echo logging"]}, + ], + }, + ) + + +def test_workspace_analyze_normalizes_shorthand() -> None: + """The analyzer expands tmuxp shorthand into the canonical spec tree.""" + ws = analyze(_YAML) + assert ws.name == "ws-offline" + assert [w.name for w in ws.windows] == ["editor", "logs"] + assert ws.windows[0].panes[0].commands == ("echo top",) + assert ws.windows[0].panes[1].commands == ("echo bottom-1", "echo bottom-2") + assert ws.windows[0].panes[1].focus is True + + +def test_workspace_compiles_to_core_ops() -> None: + """Compiling the declared workspace emits Core ops in tmuxp-faithful order.""" + kinds = [op.kind for op in analyze(_YAML).compile().operations] + assert kinds[0] == "new_session" + assert kinds.count("new_window") == 1 # window 1 is reused, window 2 created + assert "split_window" in kinds + assert "select_layout" in kinds + assert kinds[-1] == "select_window" # window focus is emitted last + + +def test_workspace_offline_build_and_planner_equivalence() -> None: + """The compiled plan runs offline and the optimizer preserves the result.""" + plan = analyze(_YAML).compile() + sequential = plan.execute(ConcreteEngine(), planner=SequentialPlanner()) + folded = plan.execute(ConcreteEngine(), planner=FoldingPlanner()) + assert sequential.ok + assert folded.ok + assert [r.status for r in sequential.results] == [r.status for r in folded.results] + + +def test_first_pane_focus_multipane_compiles() -> None: + """Focusing the first pane of a multi-pane window now compiles (captured id).""" + ws = analyze( + { + "session_name": "ws-focus", + "windows": [ + { + "window_name": "w", + "panes": [{"shell_command": ["echo a"], "focus": True}, "echo b"], + }, + ], + }, + ) + plan = ws.compile() + assert "select_pane" in [op.kind for op in plan.operations] + # offline execution resolves the first-pane sub-ref without error + assert plan.execute(ConcreteEngine()).ok + + +def test_empty_workspace_is_rejected() -> None: + """A workspace with no windows fails closed at compile.""" + with pytest.raises(WorkspaceCompileError): + Workspace(name="empty").compile() + + +def test_workspace_builder_async_control_live( + session: Session, + tmp_path: Path, +) -> None: + """Build a workspace over the async control engine; confirm live structure.""" + server = session.server + spec = _spec(str(tmp_path), name="ws-async") + + async def main() -> PlanResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await spec.abuild(engine) + + result = asyncio.run(main()) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + +def test_workspace_builder_subprocess_live( + session: Session, + tmp_path: Path, +) -> None: + """The same spec builds synchronously over the subprocess engine (neutrality).""" + server = session.server + spec = _spec(str(tmp_path), name="ws-sync") + + result = spec.build(SubprocessEngine.for_server(server)) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + +# --- Robust QA: a rich workspace exercising the full feature surface --- + + +def _rich_spec(start_directory: str, name: str = "ws-rich") -> Workspace: + """Return a three-window workspace: layouts, options, env, focus, multi-pane.""" + return analyze( + { + "session_name": name, + "start_directory": start_directory, + "on_exists": "replace", + "environment": {"WS_BUILDER": "1"}, + "options": {"history-limit": "5000"}, + "windows": [ + { + "window_name": "editor", + "layout": "main-vertical", + "options": {"main-pane-height": "12"}, + "panes": [ + {"shell_command": ["echo EDITORZERO"], "focus": True}, + "echo editor-one", + ], + }, + {"window_name": "logs", "panes": ["echo logs-zero"]}, + { + "window_name": "shell", + "focus": True, + "panes": [ + "echo shell-zero", + {"shell_command": ["echo SHELLONE"], "focus": True}, + "echo shell-two", + ], + }, + ], + }, + ) + + +def test_workspace_plan_serializes_round_trip(tmp_path: Path) -> None: + """The compiled plan (incl. SlotRef sub-refs) round-trips through to_list.""" + plan = _rich_spec(str(tmp_path)).compile() + data = plan.to_list() + restored = LazyPlan.from_list(data) + assert restored.to_list() == data + assert [o.kind for o in restored.operations] == [o.kind for o in plan.operations] + + +def test_workspace_all_planners_agree(tmp_path: Path) -> None: + """Sequential, Folding, and Marked planners give an identical PlanResult.""" + plan = _rich_spec(str(tmp_path)).compile() + runs = { + name: plan.execute(ConcreteEngine(), planner=planner()) + for name, planner in ( + ("sequential", SequentialPlanner), + ("folding", FoldingPlanner), + ("marked", MarkedPlanner), + ) + } + statuses = {name: [r.status for r in run.results] for name, run in runs.items()} + assert all(run.ok for run in runs.values()) + assert statuses["sequential"] == statuses["folding"] == statuses["marked"] + + +def test_workspace_builder_rich_subprocess(session: Session, tmp_path: Path) -> None: + """A rich workspace builds correctly: structure, focus, options, env, cwd, cmds.""" + server = session.server + spec = _rich_spec(str(tmp_path), name="ws-rich-sync") + + result = spec.build(SubprocessEngine.for_server(server)) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + built = server.sessions.filter(session_name="ws-rich-sync")[0] + windows = list(built.windows) + + # structure: names, order, per-window pane counts + assert [w.window_name for w in windows] == ["editor", "logs", "shell"] + assert [len(list(w.panes)) for w in windows] == [2, 1, 3] + + # window focus -> shell is the active window + assert built.active_window.window_name == "shell" + + # pane focus: editor's first pane + shell's middle pane are active in-window + editor, shell = windows[0], windows[2] + assert editor.active_pane is not None + assert editor.active_pane.pane_id == next(iter(editor.panes)).pane_id + assert shell.active_pane is not None + assert shell.active_pane.pane_id == list(shell.panes)[1].pane_id + + # session + window options applied + assert str(built.show_option("history-limit")) == "5000" + assert str(editor.show_option("main-pane-height")) == "12" + + # session environment set + assert built.show_environment().get("WS_BUILDER") == "1" + + # start_directory honored on a split pane (cwd settles after the shell starts) + want_cwd = str(tmp_path) + split_pane_id = list(editor.panes)[1].pane_id + + def _cwd_ok() -> bool: + pane = server.panes.get(pane_id=split_pane_id) + return pane is not None and pane.pane_current_path == want_cwd + + assert retry_until(_cwd_ok, 5, raises=False) + + # a command actually ran in the right pane + first_pane_id = next(iter(editor.panes)).pane_id + + def _cmd_ran() -> bool: + pane = server.panes.get(pane_id=first_pane_id) + return pane is not None and "EDITORZERO" in "\n".join(pane.capture_pane()) + + assert retry_until(_cmd_ran, 5, raises=False) + + +def test_workspace_builder_rich_async(session: Session, tmp_path: Path) -> None: + """The rich spec builds identically over the async control engine (neutrality).""" + server = session.server + spec = _rich_spec(str(tmp_path), name="ws-rich-async") + + async def main() -> PlanResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await spec.abuild(engine) + + result = asyncio.run(main()) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + built = server.sessions.filter(session_name="ws-rich-async")[0] + assert [w.window_name for w in built.windows] == ["editor", "logs", "shell"] + assert built.active_window.window_name == "shell" + assert str(built.show_option("history-limit")) == "5000" + + +# --- Analyzer + IR normalization (offline, no tmux) --- + + +def test_pane_commands_normalizes_run_forms() -> None: + """Pane.commands turns run (None / str / sequence) into a command tuple.""" + assert Pane().commands == () + assert Pane(run="vim").commands == ("vim",) + assert Pane(run=["cd src", "pytest -q"]).commands == ("cd src", "pytest -q") + + +def test_analyze_dimensions_list_and_mapping() -> None: + """The analyzer coerces both ``[x, y]`` and ``{width, height}`` dimensions.""" + panes = {"windows": [{"panes": ["echo a"]}]} + listed = analyze({"session_name": "s", "dimensions": [200, 50], **panes}) + mapped = analyze( + {"session_name": "s", "dimensions": {"width": 100, "height": 40}, **panes}, + ) + unset = analyze({"session_name": "s", **panes}) + assert listed.dimensions == (200, 50) + assert mapped.dimensions == (100, 40) + assert unset.dimensions is None + + +def test_analyze_shell_command_shorthand_forms() -> None: + """shell_command shorthand expands: bare string, list, and ``{cmd}`` items.""" + ws = analyze( + { + "session_name": "s", + "windows": [ + { + "panes": [ + {"shell_command": "echo solo"}, + {"shell_command": ["echo a", {"cmd": "echo b"}]}, + None, + ], + }, + ], + }, + ) + panes = ws.windows[0].panes + assert panes[0].commands == ("echo solo",) + assert panes[1].commands == ("echo a", "echo b") + assert panes[2].commands == () # a None pane is an empty (implicit) pane + + +def test_analyze_passes_through_session_fields() -> None: + """Session-level fields (env/options/before_script/on_exists) survive analysis.""" + ws = analyze( + { + "session_name": "s", + "on_exists": "replace", + "before_script": "echo setup", + "environment": {"E": "1"}, + "options": {"history-limit": "5000"}, + "windows": [{"panes": ["echo a"]}], + }, + ) + assert ws.on_exists == "replace" + assert ws.before_script == "echo setup" + assert dict(ws.environment) == {"E": "1"} + assert dict(ws.options) == {"history-limit": "5000"} + + +def test_analyze_normalizes_pane_orchestration_fields() -> None: + """Per-pane orchestration (sleeps, start_directory, focus) lands on the Pane.""" + ws = analyze( + { + "session_name": "s", + "windows": [ + { + "panes": [ + { + "shell_command": ["echo x"], + "sleep_before": 0.1, + "sleep_after": 0.2, + "start_directory": "/tmp", + "focus": True, + }, + ], + }, + ], + }, + ) + pane = ws.windows[0].panes[0] + assert (pane.sleep_before, pane.sleep_after) == (0.1, 0.2) + assert pane.start_directory == "/tmp" + assert pane.focus is True + + +def test_analyze_rejects_non_mapping_yaml() -> None: + """A YAML scalar (not a mapping) is rejected rather than silently mis-parsed.""" + with pytest.raises(TypeError): + analyze("just-a-scalar") + + +def test_analyze_rejects_unsupported_pane() -> None: + """A pane that is neither None, a string, nor a mapping fails closed.""" + with pytest.raises(TypeError): + analyze({"session_name": "s", "windows": [{"panes": [123]}]}) + + +# --- Compiler: op emission + host-step schedule (offline, no tmux) --- + + +def test_compile_threads_dimensions_into_new_session() -> None: + """Workspace dimensions become the new-session ``-x``/``-y`` width/height.""" + ws = Workspace( + name="ws-dim", + dimensions=(120, 40), + windows=[Window("w", panes=[Pane(run="echo a")])], + ) + new_session = compile_full(ws).plan.operations[0] + assert isinstance(new_session, NewSession) # first op, narrowed for its fields + assert (new_session.width, new_session.height) == (120, 40) + + +def test_compile_emits_environment_and_options() -> None: + """Session env/options and window options compile to their write ops, valued.""" + ws = Workspace( + name="ws-opts", + environment={"WS_E": "1"}, + options={"history-limit": "9000"}, + windows=[ + Window("w", options={"main-pane-height": "10"}, panes=[Pane(run="echo a")]), + ], + ) + ops = compile_full(ws).plan.operations + set_env = next(op for op in ops if isinstance(op, SetEnvironment)) + set_opt = next(op for op in ops if isinstance(op, SetOption)) + set_wopt = next(op for op in ops if isinstance(op, SetWindowOption)) + assert (set_env.name, set_env.value) == ("WS_E", "1") + assert (set_opt.option, set_opt.value) == ("history-limit", "9000") + assert (set_wopt.option, set_wopt.value) == ("main-pane-height", "10") + + +def test_compile_schedules_host_steps_off_the_op_spine() -> None: + """before_script and pane sleeps become host steps, not recorded operations.""" + ws = Workspace( + name="ws-hosts", + start_directory="/tmp", + before_script="echo hi", + windows=[ + Window( + "w", + panes=[ + Pane(run="echo a", sleep_before=0.5), + Pane(run="echo b", sleep_after=0.7), + ], + ), + ], + ) + compiled = compile_full(ws) + operations = compiled.plan.operations + + # no orchestration leaks into the pure op spine + assert {"sleep", "script"}.isdisjoint(op.kind for op in operations) + + # before_script runs before any op, carrying the session cwd + assert compiled.pre == (HostStep("script", command="echo hi", cwd="/tmp"),) + + # sleep_before is anchored just before its pane's first send-keys; + # sleep_after just after the last send-keys -- asserted by position, not index + sends = [i for i, op in enumerate(operations) if op.kind == "send_keys"] + assert HostStep("sleep", seconds=0.5) in compiled.host_after[min(sends) - 1] + assert HostStep("sleep", seconds=0.7) in compiled.host_after[max(sends)] + + +def test_compile_reuses_first_window_creating_only_the_rest() -> None: + """Window 0 reuses the session's implicit window; only 2..N create windows.""" + unnamed = compile_full(Workspace(name="s", windows=[Window(panes=[Pane(run="x")])])) + unnamed_kinds = [op.kind for op in unnamed.plan.operations] + assert "new_window" not in unnamed_kinds + assert "rename_window" not in unnamed_kinds # nothing to rename when unnamed + + named = compile_full(Workspace(name="s", windows=[Window("w", panes=[Pane("x")])])) + named_kinds = [op.kind for op in named.plan.operations] + assert "new_window" not in named_kinds + assert named_kinds.count("rename_window") == 1 # first window renamed in place + + two = compile_full( + Workspace( + name="s", + windows=[Window("a", panes=[Pane("x")]), Window("b", panes=[Pane("y")])], + ), + ) + assert [op.kind for op in two.plan.operations].count("new_window") == 1 + + +def test_compile_workspace_method_matches_compile_full_plan() -> None: + """``Workspace.compile()`` returns exactly ``compile_full().plan`` (same ops).""" + ws = Workspace(name="s", windows=[Window("w", panes=[Pane("echo a"), Pane("b")])]) + via_method = [op.kind for op in ws.compile().operations] + via_full = [op.kind for op in compile_full(ws).plan.operations] + assert via_method == via_full + + +# --- Runner preflight + confirm negative path (live tmux) --- + + +def test_workspace_before_script_runs_as_host_step( + session: Session, + tmp_path: Path, +) -> None: + """before_script executes on the host, in start_directory, before the build.""" + server = session.server + sentinel = tmp_path / "before_script.ran" + spec = analyze( + { + "session_name": "ws-before", + "start_directory": str(tmp_path), + "on_exists": "replace", + # relative path -> proves the step runs with cwd == start_directory + "before_script": f"echo ok > {sentinel.name}", + "windows": [{"window_name": "w", "panes": ["echo a"]}], + }, + ) + + result = spec.build(SubprocessEngine.for_server(server)) + assert result.ok + assert sentinel.exists() + assert sentinel.read_text().strip() == "ok" + + +def test_workspace_on_exists_reuse_skips_existing( + session: Session, + tmp_path: Path, +) -> None: + """on_exists='reuse' leaves an existing session untouched and skips the build.""" + server = session.server + engine = SubprocessEngine.for_server(server) + spec = analyze( + { + "session_name": "ws-reuse", + "start_directory": str(tmp_path), + "on_exists": "reuse", + "windows": [{"window_name": "only", "panes": ["echo a"]}], + }, + ) + + assert spec.build(engine).ok + before = [ + w.window_id for w in server.sessions.filter(session_name="ws-reuse")[0].windows + ] + + # the second build sees the session and short-circuits: empty but ok + second = spec.build(engine) + assert second.ok + assert second.results == () + after = [ + w.window_id for w in server.sessions.filter(session_name="ws-reuse")[0].windows + ] + assert before == after # untouched -- same windows, not rebuilt + + +def test_workspace_on_exists_error_raises( + session: Session, + tmp_path: Path, +) -> None: + """on_exists='error' refuses to clobber an existing session of the same name.""" + server = session.server + engine = SubprocessEngine.for_server(server) + spec = analyze( + { + "session_name": "ws-error", + "start_directory": str(tmp_path), + "on_exists": "error", + "windows": [{"window_name": "w", "panes": ["echo a"]}], + }, + ) + + assert spec.build(engine).ok + with pytest.raises(FileExistsError): + spec.build(engine) + + +def test_workspace_confirm_detects_structural_mismatch( + session: Session, + tmp_path: Path, +) -> None: + """Confirm flags a problem when the live session diverges from the spec.""" + server = session.server + built = analyze( + { + "session_name": "ws-confirm", + "start_directory": str(tmp_path), + "on_exists": "replace", + "windows": [{"window_name": "only", "panes": ["echo a"]}], + }, + ) + assert built.build(SubprocessEngine.for_server(server)).ok + assert confirm(built, server).ok # matches what was actually built + + # a spec declaring more windows than were built must be flagged + divergent = analyze( + { + "session_name": "ws-confirm", + "windows": [ + {"window_name": "only", "panes": ["echo a"]}, + {"window_name": "extra", "panes": ["echo b"]}, + ], + }, + ) + report = confirm(divergent, server) + assert not report.ok + assert any("window count" in problem for problem in report.problems) diff --git a/tests/experimental/contract/test_async_engine.py b/tests/experimental/contract/test_async_engine.py new file mode 100644 index 000000000..95581b4ff --- /dev/null +++ b/tests/experimental/contract/test_async_engine.py @@ -0,0 +1,83 @@ +"""Async engine against a real tmux server, and parity with the classic engine. + +Uses :func:`asyncio.run` to drive :func:`arun` so the async transport is +exercised end to end without a pytest-asyncio dependency. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import AsyncSubprocessEngine, SubprocessEngine +from libtmux.experimental.ops import SplitWindow, arun, run +from libtmux.experimental.ops._types import WindowId +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_async_run_cancellation_suppresses_terminate_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cancellation propagates even when terminate() races a process exit.""" + from libtmux.experimental.engines.base import CommandRequest + + class _FakeProc: + returncode = 0 + + async def communicate(self) -> tuple[bytes, bytes]: + raise asyncio.CancelledError + + def terminate(self) -> None: + raise ProcessLookupError + + async def wait(self) -> int: + return 0 + + async def _fake_exec(*_args: object, **_kwargs: object) -> _FakeProc: + return _FakeProc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + engine = AsyncSubprocessEngine(tmux_bin="tmux") + + async def _check() -> None: + with pytest.raises(asyncio.CancelledError): + await engine.run(CommandRequest.from_args("display-message", "-p", "x")) + + asyncio.run(_check()) + + +def test_async_split_creates_real_pane(session: Session) -> None: + """An async split returns a typed result whose new pane really exists.""" + server = session.server + window = session.active_window + assert window.window_id is not None + engine = AsyncSubprocessEngine.for_server(server) + + result = asyncio.run(arun(SplitWindow(target=WindowId(window.window_id)), engine)) + + assert isinstance(result, SplitWindowResult) + assert result.ok + assert result.new_pane_id is not None + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_async_sync_parity(session: Session) -> None: + """The async and sync classic engines agree on result type and argv.""" + server = session.server + window = session.active_window + assert window.window_id is not None + operation = SplitWindow(target=WindowId(window.window_id)) + + sync_result = run(operation, SubprocessEngine.for_server(server)) + async_result = asyncio.run( + arun(operation, AsyncSubprocessEngine.for_server(server)), + ) + + assert type(sync_result) is type(async_result) is SplitWindowResult + assert sync_result.argv == async_result.argv == operation.render() + assert sync_result.ok and async_result.ok diff --git a/tests/experimental/contract/test_classic_engine.py b/tests/experimental/contract/test_classic_engine.py new file mode 100644 index 000000000..927a6be3a --- /dev/null +++ b/tests/experimental/contract/test_classic_engine.py @@ -0,0 +1,96 @@ +"""Classic engine against a real tmux server, and parity with concrete. + +These use the libtmux pytest fixtures (a live tmux server), so they exercise the +classic :class:`~libtmux.experimental.engines.subprocess.SubprocessEngine` path +end to end and assert it returns the *same typed result shape* the concrete +engine does. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.results import ( + AckResult, + CapturePaneResult, + SplitWindowResult, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_classic_split_creates_real_pane(session: Session) -> None: + """A classic split returns a typed result whose new pane really exists.""" + server = session.server + window = session.active_window + assert window.window_id is not None + engine = SubprocessEngine.for_server(server) + + result = run(SplitWindow(target=WindowId(window.window_id)), engine) + + assert isinstance(result, SplitWindowResult) + assert result.ok + assert result.new_pane_id is not None + assert result.new_pane_id.startswith("%") + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_classic_send_keys_and_select_layout(session: Session) -> None: + """Classic send-keys and select-layout return successful typed results.""" + server = session.server + pane = session.active_pane + window = session.active_window + assert pane is not None + assert pane.pane_id is not None + assert window.window_id is not None + engine = SubprocessEngine.for_server(server) + + sent = run(SendKeys(target=PaneId(pane.pane_id), keys="echo hi"), engine) + assert type(sent) is AckResult + assert sent.ok + + laid_out = run( + SelectLayout(target=WindowId(window.window_id), layout="even-horizontal"), + engine, + ) + assert laid_out.ok + + +def test_classic_capture_returns_lines(session: Session) -> None: + """Classic capture-pane returns a typed result carrying line data.""" + server = session.server + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + engine = SubprocessEngine.for_server(server) + + result = run(CapturePane(target=PaneId(pane.pane_id)), engine) + + assert isinstance(result, CapturePaneResult) + assert result.ok + assert isinstance(result.lines, tuple) + + +def test_classic_concrete_parity(session: Session) -> None: + """Classic and concrete engines agree on result type and argv (not payload).""" + server = session.server + window = session.active_window + assert window.window_id is not None + operation = SplitWindow(target=WindowId(window.window_id)) + + classic = run(operation, SubprocessEngine.for_server(server)) + concrete = run(operation, ConcreteEngine()) + + assert type(classic) is type(concrete) is SplitWindowResult + assert classic.argv == concrete.argv == operation.render() + assert classic.ok and concrete.ok diff --git a/tests/experimental/contract/test_control_engine.py b/tests/experimental/contract/test_control_engine.py new file mode 100644 index 000000000..dd280cc98 --- /dev/null +++ b/tests/experimental/contract/test_control_engine.py @@ -0,0 +1,100 @@ +"""Control-mode engine against a real tmux server, and parity with concrete. + +Exercises the persistent ``tmux -C`` engine end to end and asserts it returns +the same typed result shape the other engines do. The engine is used as a +context manager so the control connection is always torn down. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, ControlModeEngine +from libtmux.experimental.ops import ( + CapturePane, + SendKeys, + SplitWindow, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.results import ( + AckResult, + CapturePaneResult, + SplitWindowResult, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_control_sequential_commands_stay_aligned(session: Session) -> None: + """Many sequential commands keep result alignment (drain-between-calls path). + + The first command must get the real result (not the consumed startup ACK), + and each subsequent call drains any unsolicited blocks before reading its own. + """ + server = session.server + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + + with ControlModeEngine.for_server(server) as engine: + for index in range(5): + result = run( + SendKeys(target=PaneId(pane.pane_id), keys=f"# {index}"), engine + ) + assert result.ok + captured = run(CapturePane(target=PaneId(pane.pane_id)), engine) + + assert isinstance(captured, CapturePaneResult) + assert captured.ok + + +def test_control_split_creates_real_pane(session: Session) -> None: + """A control-mode split returns a typed result whose pane really exists.""" + server = session.server + window = session.active_window + assert window.window_id is not None + + with ControlModeEngine.for_server(server) as engine: + result = run(SplitWindow(target=WindowId(window.window_id)), engine) + + assert isinstance(result, SplitWindowResult) + assert result.ok + assert result.new_pane_id is not None + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_control_batches_multiple_commands(session: Session) -> None: + """run_batch pipelines several commands over one connection, one result each.""" + server = session.server + pane = session.active_pane + window = session.active_window + assert pane is not None + assert pane.pane_id is not None + assert window.window_id is not None + + with ControlModeEngine.for_server(server) as engine: + sent = run(SendKeys(target=PaneId(pane.pane_id), keys="echo hi"), engine) + captured = run(CapturePane(target=PaneId(pane.pane_id)), engine) + + assert type(sent) is AckResult + assert sent.ok + assert isinstance(captured, CapturePaneResult) + assert captured.ok + + +def test_control_concrete_parity(session: Session) -> None: + """Control-mode and concrete engines agree on result type and argv.""" + server = session.server + window = session.active_window + assert window.window_id is not None + operation = SplitWindow(target=WindowId(window.window_id)) + + with ControlModeEngine.for_server(server) as engine: + control = run(operation, engine) + concrete = run(operation, ConcreteEngine()) + + assert type(control) is type(concrete) is SplitWindowResult + assert control.argv == concrete.argv == operation.render() + assert control.ok and concrete.ok diff --git a/tests/experimental/contract/test_engine_contract.py b/tests/experimental/contract/test_engine_contract.py new file mode 100644 index 000000000..34e60096c --- /dev/null +++ b/tests/experimental/contract/test_engine_contract.py @@ -0,0 +1,67 @@ +"""Engine-agnostic operation contract (runs offline via the concrete engine). + +These assertions hold for *any* engine because they are properties of the +operation executed through the engine: the result is the operation's typed +result class, its argv is the operation's render, and it serializes round-trip. +The concrete engine lets the whole matrix run without a tmux server. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + +_CONTRACT_OPS = [ + pytest.param(SplitWindow(target=WindowId("@1")), id="split_window"), + pytest.param(CapturePane(target=PaneId("%1")), id="capture_pane"), + pytest.param(SendKeys(target=PaneId("%1"), keys="echo hi"), id="send_keys"), + pytest.param( + SelectLayout(target=WindowId("@1"), layout="tiled"), id="select_layout" + ), +] + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_result_type_matches_operation(operation: Operation[t.Any]) -> None: + """An engine returns the operation's declared result type.""" + result = run(operation, ConcreteEngine()) + assert type(result) is operation.result_cls + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_result_argv_is_render(operation: Operation[t.Any]) -> None: + """The result's argv equals the operation's pure render.""" + result = run(operation, ConcreteEngine()) + assert result.argv == operation.render() + assert result.ok + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_result_serialization_round_trip(operation: Operation[t.Any]) -> None: + """A result produced by an engine survives a dict round-trip.""" + result = run(operation, ConcreteEngine()) + assert result_from_dict(result_to_dict(result)) == result + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_same_result_across_engine_instances(operation: Operation[t.Any]) -> None: + """Two fresh engines yield equal typed results -- determinism contract.""" + first = run(operation, ConcreteEngine()) + second = run(operation, ConcreteEngine()) + assert first == second diff --git a/tests/experimental/engines/__init__.py b/tests/experimental/engines/__init__.py new file mode 100644 index 000000000..bdf24ec3b --- /dev/null +++ b/tests/experimental/engines/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.engines.""" + +from __future__ import annotations diff --git a/tests/experimental/engines/test_base.py b/tests/experimental/engines/test_base.py new file mode 100644 index 000000000..5484f9a7d --- /dev/null +++ b/tests/experimental/engines/test_base.py @@ -0,0 +1,50 @@ +"""Tests for engine base helpers.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines.base import render_control_line + + +class WireCase(t.NamedTuple): + """An argv and the control-mode wire line it should render to.""" + + test_id: str + argv: tuple[str, ...] + expected: str + + +WIRE_CASES = ( + WireCase( + test_id="plain", + argv=("rename-window", "-t", "@1", "edit"), + expected="rename-window -t @1 edit", + ), + WireCase( + test_id="quotes_spaces", + argv=("set-option", "@x", "a b"), + expected="set-option @x 'a b'", + ), + WireCase( + test_id="chain_keeps_bare_semicolon", + argv=("rename-window", "a", ";", "kill-window", "@2"), + expected="rename-window a ; kill-window @2", + ), +) + + +@pytest.mark.parametrize( + list(WireCase._fields), + WIRE_CASES, + ids=[c.test_id for c in WIRE_CASES], +) +def test_render_control_line( + test_id: str, + argv: tuple[str, ...], + expected: str, +) -> None: + """A standalone ``;`` stays a separator; other tokens are shell-quoted.""" + assert render_control_line(argv) == expected diff --git a/tests/experimental/engines/test_control_mode_correlation.py b/tests/experimental/engines/test_control_mode_correlation.py new file mode 100644 index 000000000..6cd20d77c --- /dev/null +++ b/tests/experimental/engines/test_control_mode_correlation.py @@ -0,0 +1,159 @@ +"""Tests for control-mode block correlation (folded chains, merge).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines.control_mode import ( + ControlModeBlock, + _merge_blocks, + command_count, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class CountCase(t.NamedTuple): + """An argv and the number of tmux commands it runs.""" + + test_id: str + argv: tuple[str, ...] + expected: int + + +COUNT_CASES = ( + CountCase("single", ("rename-window", "-t", "@1", "a"), 1), + CountCase("two", ("rename-window", "a", ";", "kill-window", "@2"), 2), + CountCase("three", ("a", ";", "b", ";", "c"), 3), + CountCase("literal_semicolon_arg", ("send-keys", "-t", "%1", "a;b"), 1), +) + + +@pytest.mark.parametrize( + list(CountCase._fields), + COUNT_CASES, + ids=[c.test_id for c in COUNT_CASES], +) +def test_command_count(test_id: str, argv: tuple[str, ...], expected: int) -> None: + """Only a standalone ``;`` token counts as a command separator.""" + assert command_count(argv) == expected + + +def _block(*, is_error: bool, body: tuple[bytes, ...]) -> ControlModeBlock: + return ControlModeBlock(number=1, flags=1, is_error=is_error, body=body) + + +class MergeCase(t.NamedTuple): + """Blocks from one (possibly folded) request and the merged result.""" + + test_id: str + blocks: list[ControlModeBlock] + returncode: int + stdout: tuple[str, ...] + stderr: tuple[str, ...] + + +MERGE_CASES = ( + MergeCase("single_ok", [_block(is_error=False, body=(b"%1",))], 0, ("%1",), ()), + MergeCase("single_err", [_block(is_error=True, body=(b"boom",))], 1, (), ("boom",)), + MergeCase( + "chain_all_ok", + [_block(is_error=False, body=(b"a",)), _block(is_error=False, body=(b"b",))], + 0, + ("a", "b"), + (), + ), + MergeCase( + "chain_second_fails", + [_block(is_error=False, body=(b"a",)), _block(is_error=True, body=(b"boom",))], + 1, + ("a",), + ("boom",), + ), +) + + +@pytest.mark.parametrize( + list(MergeCase._fields), + MERGE_CASES, + ids=[c.test_id for c in MERGE_CASES], +) +def test_merge_blocks( + test_id: str, + blocks: list[ControlModeBlock], + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], +) -> None: + """A folded request's blocks merge; any sub-command error fails the result.""" + result = _merge_blocks(blocks, ("cmd",)) + assert result.returncode == returncode + assert result.stdout == stdout + assert result.stderr == stderr + + +def test_async_control_write_failure_clears_pending() -> None: + """A write failure removes the queued futures so the FIFO stays aligned.""" + import asyncio + + from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + from libtmux.experimental.engines.base import CommandRequest + from libtmux.experimental.engines.control_mode import ControlModeError + + class _FakeStdin: + def write(self, _data: bytes) -> None: + raise BrokenPipeError + + async def drain(self) -> None: ... + + class _FakeProc: + stdin = _FakeStdin() + + async def _check() -> None: + engine = AsyncControlModeEngine() + engine._started = True + engine._proc = t.cast("t.Any", _FakeProc()) + with pytest.raises(ControlModeError): + await engine.run_batch([CommandRequest.from_args("list-sessions")]) + assert not engine._pending + + asyncio.run(_check()) + + +def test_control_mode_fold_detects_failure_live(session: Session) -> None: + """A folded chain over control mode surfaces a later sub-command's failure.""" + from libtmux.experimental.engines.control_mode import ControlModeEngine + from libtmux.experimental.ops import FoldingPlanner, LazyPlan, RenameWindow + from libtmux.experimental.ops._types import WindowId + + window = session.active_window + assert window.window_id is not None + with ControlModeEngine.for_server(session.server) as engine: + plan = LazyPlan() + plan.add(RenameWindow(target=WindowId(window.window_id), name="ok")) + plan.add(RenameWindow(target=WindowId("@999999"), name="x")) # bad target + outcome = plan.execute(engine, planner=FoldingPlanner()) + # The second sub-command's failure is no longer swallowed (was reported ok). + assert not outcome.ok + + +def test_control_mode_fold_runs_all_live(session: Session) -> None: + """A folded chain over control mode runs every sub-command.""" + from libtmux.experimental.engines.control_mode import ControlModeEngine + from libtmux.experimental.ops import FoldingPlanner, LazyPlan, RenameWindow + from libtmux.experimental.ops._types import WindowId + + second = session.new_window(window_name="orig") + first = session.active_window + assert first.window_id is not None and second.window_id is not None + with ControlModeEngine.for_server(session.server) as engine: + plan = LazyPlan() + plan.add(RenameWindow(target=WindowId(first.window_id), name="one")) + plan.add(RenameWindow(target=WindowId(second.window_id), name="two")) + outcome = plan.execute(engine, planner=FoldingPlanner()) + assert outcome.ok + second.refresh() + assert second.window_name == "two" diff --git a/tests/experimental/engines/test_imsg.py b/tests/experimental/engines/test_imsg.py new file mode 100644 index 000000000..c70e39abe --- /dev/null +++ b/tests/experimental/engines/test_imsg.py @@ -0,0 +1,153 @@ +"""Tests for the native imsg engine (codec unit tests + live tmux parity). + +The prototype this is ported from only ever tested against a fake socketpair +server; the live parity test here is the real wire-compatibility proof against a +tmux built from source, and it runs across the CI tmux matrix. +""" + +from __future__ import annotations + +import socket +import typing as t + +import pytest + +from libtmux.experimental.engines import ( + CommandRequest, + ImsgEngine, + SubprocessEngine, + available_engines, + create_engine, +) +from libtmux.experimental.engines.imsg.v8 import ( + IMSG_HEADER_SIZE, + MessageType, + ProtocolV8Codec, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + +needs_af_unix = pytest.mark.skipif( + not hasattr(socket, "AF_UNIX"), + reason="imsg engine needs AF_UNIX sockets (POSIX only)", +) + + +@needs_af_unix +def test_imsg_connect_socket_failure_raises_oserror( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A socket() failure surfaces as OSError, not UnboundLocalError.""" + import errno + + def _boom(*_args: object, **_kwargs: object) -> object: + raise OSError(errno.EMFILE, "too many open files") + + monkeypatch.setattr( + "libtmux.experimental.engines.imsg.base.socket.socket", + _boom, + ) + with pytest.raises(OSError, match="too many open files"): + ImsgEngine()._connect(socket_path="/nonexistent") + + +def test_imsg_registered() -> None: + """The imsg engine is registered and constructible by name.""" + assert "imsg" in available_engines() + assert type(create_engine("imsg")).__name__ == "ImsgEngine" + + +def test_v8_codec_header_round_trip() -> None: + """A v8 frame packs to wire bytes and its header unpacks back (no tmux).""" + codec = ProtocolV8Codec() + payload = b"hello\x00" + frame = codec.frame_message(200, payload, peer_id=8) + wire = codec.pack_frame(frame) + + assert len(wire) == IMSG_HEADER_SIZE + len(payload) + header = codec.unpack_header(wire[:IMSG_HEADER_SIZE]) + assert header.msg_type == 200 + assert header.peer_id == 8 # peer_id carries PROTOCOL_VERSION + assert header.length == IMSG_HEADER_SIZE + len(payload) + assert header.has_fd is False + + +def test_v8_command_message_packs_argc_and_argv() -> None: + """A MSG_COMMAND frame encodes argc + NUL-joined argv (no tmux).""" + codec = ProtocolV8Codec() + frame = codec.command_message(("list-sessions", "-F", "#{session_id}"), peer_id=8) + # int32 argc=3 then three NUL-terminated args + assert frame.payload.startswith(b"\x03\x00\x00\x00") + assert frame.payload.endswith(b"#{session_id}\x00") + + +class IdentifyFrameCase(t.NamedTuple): + """One expected identify-burst frame count.""" + + test_id: str + msg_type: MessageType + expected: int + + +IDENTIFY_FRAME_CASES = ( + IdentifyFrameCase("longflags-once", MessageType.MSG_IDENTIFY_LONGFLAGS, 1), + IdentifyFrameCase("stdin-once", MessageType.MSG_IDENTIFY_STDIN, 1), + IdentifyFrameCase("stdout-once", MessageType.MSG_IDENTIFY_STDOUT, 1), + IdentifyFrameCase("done-once", MessageType.MSG_IDENTIFY_DONE, 1), + IdentifyFrameCase("environ-one-per-var", MessageType.MSG_IDENTIFY_ENVIRON, 2), +) + + +@pytest.mark.parametrize( + "case", + IDENTIFY_FRAME_CASES, + ids=[case.test_id for case in IDENTIFY_FRAME_CASES], +) +def test_identify_burst_frame_counts(case: IdentifyFrameCase) -> None: + """The identify burst emits each message type the expected number of times. + + A real tmux client sends ``MSG_IDENTIFY_LONGFLAGS`` exactly once. + """ + frames = ProtocolV8Codec().identify_messages( + cwd="/tmp", + term="xterm", + tty_name="", + client_pid=123, + environ={"A": "1", "B": "2"}, + ) + count = sum(1 for frame in frames if frame.header.msg_type == int(case.msg_type)) + assert count == case.expected + + +def _socket_prefix(server: t.Any) -> tuple[str, ...]: + """Build the -L/-S flag that targets the test server's socket.""" + if server.socket_name: + return (f"-L{server.socket_name}",) + return (f"-S{server.socket_path}",) + + +@needs_af_unix +def test_imsg_subprocess_parity(session: Session) -> None: + """Imsg and subprocess engines return identical output for read commands. + + This is the wire-compatibility proof: the same typed CommandResult from + speaking tmux's binary protocol directly and from forking the tmux CLI. + """ + server = session.server + prefix = _socket_prefix(server) + session_id = session.session_id + assert session_id is not None + imsg = ImsgEngine() + classic = SubprocessEngine() + + def parity(*cmd: str) -> None: + request = CommandRequest.from_args(*prefix, *cmd) + via_imsg = imsg.run(request) + via_subprocess = classic.run(request) + assert via_imsg.returncode == via_subprocess.returncode, cmd + assert via_imsg.stdout == via_subprocess.stdout, cmd + + parity("display-message", "-p", "-t", session_id, "#{session_id}") + parity("list-sessions", "-F", "#{session_id}") + parity("has-session", "-t", session_id) diff --git a/tests/experimental/engines/test_registry.py b/tests/experimental/engines/test_registry.py new file mode 100644 index 000000000..7e315cc60 --- /dev/null +++ b/tests/experimental/engines/test_registry.py @@ -0,0 +1,63 @@ +"""Tests for the engine registry and EngineKind/EngineSpec.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux import exc +from libtmux.experimental.engines import ( + EngineKind, + EngineSpec, + available_engines, + create_engine, +) + + +def test_available_engines_are_registered() -> None: + """The registry exposes exactly the constructable (sync) engine kinds.""" + assert set(available_engines()) == { + "subprocess", + "concrete", + "control_mode", + "imsg", + } + + +def test_asyncio_kind_removed() -> None: + """The unwired ``asyncio`` kind/spec is gone; async engines are direct-ctor.""" + assert "asyncio" not in {kind.value for kind in EngineKind} + assert not hasattr(EngineSpec, "asyncio") + + +class CreateCase(t.NamedTuple): + """A registered engine name that ``create_engine`` should build.""" + + test_id: str + name: str + + +CREATE_CASES = ( + CreateCase("subprocess", "subprocess"), + CreateCase("concrete", "concrete"), + CreateCase("control_mode", "control_mode"), +) + + +@pytest.mark.parametrize( + list(CreateCase._fields), + CREATE_CASES, + ids=[c.test_id for c in CREATE_CASES], +) +def test_create_engine_builds_registered(test_id: str, name: str) -> None: + """create_engine returns an engine with the run/run_batch protocol.""" + engine = create_engine(name) + assert hasattr(engine, "run") + assert hasattr(engine, "run_batch") + + +def test_create_engine_unknown_fails() -> None: + """An unregistered name (incl. the removed 'asyncio') fails closed.""" + with pytest.raises(exc.LibTmuxException, match="unknown tmux engine"): + create_engine("asyncio") diff --git a/tests/experimental/engines/test_subprocess.py b/tests/experimental/engines/test_subprocess.py new file mode 100644 index 000000000..111bb2444 --- /dev/null +++ b/tests/experimental/engines/test_subprocess.py @@ -0,0 +1,39 @@ +"""Tests for the classic SubprocessEngine.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import SubprocessEngine +from libtmux.experimental.engines.base import CommandRequest + + +class _FakeProcess: + """Minimal stand-in for a Popen process.""" + + returncode = 0 + + def communicate(self) -> tuple[str, str]: + """Return empty stdout/stderr.""" + return ("", "") + + +def test_subprocess_engine_decodes_utf8(monkeypatch: pytest.MonkeyPatch) -> None: + """The engine decodes tmux output as UTF-8 (matching common.tmux_cmd).""" + captured: dict[str, t.Any] = {} + + def fake_popen(_cmd: t.Any, **kwargs: t.Any) -> _FakeProcess: + captured.update(kwargs) + return _FakeProcess() + + monkeypatch.setattr( + "libtmux.experimental.engines.subprocess.subprocess.Popen", + fake_popen, + ) + + engine = SubprocessEngine(tmux_bin="tmux") + engine.run(CommandRequest.from_args("display-message", "-p", "x")) + + assert captured["encoding"] == "utf-8" diff --git a/tests/experimental/facade/__init__.py b/tests/experimental/facade/__init__.py new file mode 100644 index 000000000..a260ebd4d --- /dev/null +++ b/tests/experimental/facade/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.facade.""" + +from __future__ import annotations diff --git a/tests/experimental/facade/test_facade_matrix.py b/tests/experimental/facade/test_facade_matrix.py new file mode 100644 index 000000000..0e37df75f --- /dev/null +++ b/tests/experimental/facade/test_facade_matrix.py @@ -0,0 +1,90 @@ +"""Tests for the facade matrix (scope x mode) over the shared spine.""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.facade import ( + AsyncPane, + AsyncWindow, + EagerPane, + EagerServer, + EagerWindow, + LazyWindow, +) +from libtmux.experimental.ops import LazyPlan +from libtmux.experimental.ops._types import WindowId + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_eager_full_navigation_offline() -> None: + """Eager Server->Session->Window->Pane navigation via the concrete engine.""" + server = EagerServer(ConcreteEngine()) + session = server.new_session(name="work") + assert session.session_id == "$1" + window = session.new_window(name="build") + assert window.window_id == "@1" + pane = window.split(horizontal=True) + assert isinstance(pane, EagerPane) + assert pane.pane_id == "%1" + + +def test_eager_window_methods() -> None: + """EagerWindow rename/select_layout/kill return successful results.""" + window = EagerWindow(ConcreteEngine(), "@1") + assert window.rename("x").ok + assert window.select_layout("tiled").ok + assert window.kill().ok + + +def test_lazy_window_records_and_executes() -> None: + """LazyWindow records ops and resolves the new pane on execute.""" + plan = LazyPlan() + window = LazyWindow(plan, WindowId("@1")) + window.split() + window.rename("build") + assert len(plan) == 2 + + outcome = plan.execute(ConcreteEngine()) + assert outcome.ok + assert outcome.results[0].created_id == "%1" + + +def test_async_window_and_pane() -> None: + """Async facades mirror the eager ones via await.""" + + async def main() -> tuple[str, bool]: + window = AsyncWindow(AsyncConcreteEngine(), "@1") + pane = await window.split() + assert isinstance(pane, AsyncPane) + sent = await pane.send_keys("echo hi", enter=True) + return pane.pane_id, sent.ok + + pane_id, ok = asyncio.run(main()) + assert pane_id == "%1" + assert ok + + +def test_eager_navigation_live(session: Session) -> None: + """Eager facade builds a real session/window/pane against tmux, then cleans up.""" + server = session.server + facade = EagerServer.for_server(server) + + created = facade.new_session(name="facade-matrix-test") + try: + assert created.session_id.startswith("$") + assert server.sessions.get(session_id=created.session_id) is not None + + window = created.new_window(name="built") + assert window.window_id.startswith("@") + assert server.windows.get(window_id=window.window_id) is not None + + pane = window.split(horizontal=True) + assert pane.pane_id.startswith("%") + assert server.panes.get(pane_id=pane.pane_id) is not None + finally: + created.kill() diff --git a/tests/experimental/facade/test_matrix_complete.py b/tests/experimental/facade/test_matrix_complete.py new file mode 100644 index 000000000..b5381a168 --- /dev/null +++ b/tests/experimental/facade/test_matrix_complete.py @@ -0,0 +1,69 @@ +"""Tests completing the facade matrix: lazy/async Server+Session and Client.""" + +from __future__ import annotations + +import asyncio + +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.facade import ( + AsyncClient, + AsyncServer, + EagerClient, + LazyClient, + LazyServer, +) +from libtmux.experimental.ops import LazyPlan + + +def test_lazy_server_session_window_plan() -> None: + """LazyServer records a full Server->Session->Window creation plan.""" + plan = LazyPlan() + server = LazyServer(plan) + session = server.new_session(name="work") + window = session.new_window(name="build") + window.split() + assert len(plan) == 3 # new-session, new-window, split-window + + outcome = plan.execute(ConcreteEngine()) + assert outcome.ok + assert [r.created_id for r in outcome.results] == ["$1", "@1", "%1"] + + +def test_async_server_navigation() -> None: + """AsyncServer->AsyncSession->AsyncWindow navigation via await.""" + + async def main() -> str: + server = AsyncServer(AsyncConcreteEngine()) + session = await server.new_session(name="work") + window = await session.new_window() + pane = await window.split() + return pane.pane_id + + assert asyncio.run(main()) == "%1" + + +def test_eager_client_methods() -> None: + """EagerClient detach/refresh/switch_to return successful results.""" + client = EagerClient(ConcreteEngine(), "/dev/pts/3") + assert client.refresh().ok + assert client.switch_to("$1").ok + assert client.detach().ok + + +def test_lazy_client_records() -> None: + """LazyClient records client ops into a plan.""" + plan = LazyPlan() + client = LazyClient(plan, "/dev/pts/3") + client.refresh().switch_to("$1") + assert [op.kind for op in plan] == ["refresh_client", "switch_client"] + assert plan.execute(ConcreteEngine()).ok + + +def test_async_client() -> None: + """AsyncClient mirrors the eager client via await.""" + + async def main() -> bool: + client = AsyncClient(AsyncConcreteEngine(), "/dev/pts/3") + return (await client.refresh()).ok + + assert asyncio.run(main()) diff --git a/tests/experimental/facade/test_pane_facade.py b/tests/experimental/facade/test_pane_facade.py new file mode 100644 index 000000000..d5508691a --- /dev/null +++ b/tests/experimental/facade/test_pane_facade.py @@ -0,0 +1,62 @@ +"""Tests for the eager and lazy pane facades.""" + +from __future__ import annotations + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.facade import EagerPane, LazyPane +from libtmux.experimental.ops import LazyPlan +from libtmux.experimental.ops._types import PaneId +from libtmux.experimental.ops.results import SplitWindowResult + + +def test_eager_split_returns_live_pane() -> None: + """EagerPane.split executes now and returns a live EagerPane handle.""" + pane = EagerPane(ConcreteEngine(), "%0") + child = pane.split(horizontal=True) + assert isinstance(child, EagerPane) + assert child.pane_id == "%1" + + +def test_eager_capture_and_send() -> None: + """Eager capture/send-keys return typed results.""" + engine = ConcreteEngine(capture_lines=("a", "b")) + pane = EagerPane(engine, "%1") + assert pane.capture().lines == ("a", "b") + assert pane.send_keys("echo hi", enter=True).ok + + +def test_lazy_split_returns_deferred_handle_and_defers() -> None: + """LazyPane.split records into a plan and returns a deferred LazyPane.""" + plan = LazyPlan() + root = LazyPane(plan, PaneId("%0")) + child = root.split() + assert isinstance(child, LazyPane) + assert len(plan) == 1 # recorded, not executed + + +def test_lazy_chain_resolves_forward_ref_on_execute() -> None: + """A lazy chain resolves the new pane's id when the plan runs.""" + plan = LazyPlan() + root = LazyPane(plan, PaneId("%0")) + root.split().send_keys("vim", enter=True) + + outcome = plan.execute(ConcreteEngine()) + + first = outcome.results[0] + assert isinstance(first, SplitWindowResult) + assert first.new_pane_id == "%1" + assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") + + +def test_same_operation_backs_both_facades() -> None: + """Eager and lazy facades render the identical underlying operation argv.""" + eager_engine = ConcreteEngine() + eager = EagerPane(eager_engine, "%0") + # Capture the eager split's rendered argv via the engine-independent op. + plan = LazyPlan() + LazyPane(plan, PaneId("%0")).split(horizontal=True) + lazy_argv = plan.operations[0].render() + + eager_child = eager.split(horizontal=True) + assert eager_child.pane_id # executed + assert lazy_argv == ("split-window", "-t", "%0", "-h", "-P", "-F", "#{pane_id}") diff --git a/tests/experimental/mcp/__init__.py b/tests/experimental/mcp/__init__.py new file mode 100644 index 000000000..83655d469 --- /dev/null +++ b/tests/experimental/mcp/__init__.py @@ -0,0 +1 @@ +"""Tests for the framework-agnostic MCP projection tier.""" diff --git a/tests/experimental/mcp/conftest.py b/tests/experimental/mcp/conftest.py new file mode 100644 index 000000000..e076c1fef --- /dev/null +++ b/tests/experimental/mcp/conftest.py @@ -0,0 +1,18 @@ +"""Shared fixtures for the experimental MCP tests.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _hermetic_caller_discovery(monkeypatch: pytest.MonkeyPatch) -> None: + """Disable the ``/proc`` parent walk by default so server builds are hermetic. + + ``build_*_server`` defaults its caller to ``CallerContext.discover()``, which + would otherwise walk the test host's process tree (host-dependent). Tests that + exercise discovery pass explicit readers/environ to ``discover`` and are + unaffected; tests that want a caller monkeypatch ``TMUX_PANE`` (the + process-env source, which wins before the walk). + """ + monkeypatch.setenv("LIBTMUX_MCP_DISCOVER", "0") diff --git a/tests/experimental/mcp/test_adapter_async.py b/tests/experimental/mcp/test_adapter_async.py new file mode 100644 index 000000000..08a7b6904 --- /dev/null +++ b/tests/experimental/mcp/test_adapter_async.py @@ -0,0 +1,93 @@ +"""The async-first FastMCP adapter -- awaited tools, per-op, and plan tiers. + +Exercised offline via an in-process FastMCP client over a sync ``ConcreteEngine`` +wrapped into the async protocol, so the async registration path is validated with +no tmux. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + +fastmcp = pytest.importorskip("fastmcp") + + +def _async_server(**kwargs: t.Any) -> t.Any: + """Build an async server over a wrapped in-memory engine.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + return build_async_server( + SyncToAsyncEngine(ConcreteEngine()), events="off", **kwargs + ) + + +def test_async_server_exposes_curated_and_conveniences() -> None: + """The async server surfaces the curated verbs plus the new conveniences.""" + + async def main() -> set[str]: + async with fastmcp.Client(_async_server()) as client: + return {tool.name for tool in await client.list_tools()} + + names = asyncio.run(main()) + expected = { + "create_session", + "grep_pane", + "capture_active_pane", + "resolve_relative_pane", + "find_pane_by_position", + "select_pane", + "resize_pane", + "run_tmux", + "list_clients", + "has_session", + } + assert expected <= names + # The per-op surface is hidden by default. + assert not any(name.startswith("op_") for name in names) + + +def test_async_tool_call_returns_typed_data() -> None: + """An awaited curated tool returns its typed result over the client.""" + + async def main() -> t.Any: + async with fastmcp.Client(_async_server()) as client: + result = await client.call_tool("create_session", {"name": "dev"}) + raw = await client.call_tool("run_tmux", {"args": ["list-sessions"]}) + return result.data, raw.data + + session, raw = asyncio.run(main()) + assert session.session_id == "$1" + assert raw.ok is True + + +def test_async_per_op_dispatch() -> None: + """A hidden per-op tool dispatches via the async run path.""" + + async def main() -> t.Any: + async with fastmcp.Client(_async_server(expose_operations=True)) as client: + result = await client.call_tool("op_list_sessions", {}) + return result.data + + data = asyncio.run(main()) + assert data["status"] == "complete" + + +def test_async_plan_preview() -> None: + """The pure preview_plan tool is registered on the async server.""" + + async def main() -> t.Any: + async with fastmcp.Client(_async_server()) as client: + result = await client.call_tool( + "preview_plan", + {"operations": [{"kind": "start_server"}]}, + ) + return result.data + + data = asyncio.run(main()) + assert data["ok"] is True diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py new file mode 100644 index 000000000..802b9b77a --- /dev/null +++ b/tests/experimental/mcp/test_caller.py @@ -0,0 +1,464 @@ +"""Caller context: environment parsing, strict socket scoping, and surfacing. + +Pure tests cover :class:`CallerContext.from_env` and the strict ``is_caller`` +comparator over literal env mappings; in-process FastMCP ``Client`` tests cover +the ``get_caller_context`` tool, the ``is_caller`` instruction sentence, and (live) +the ``is_caller`` row flag against a real tmux server. No pytest-asyncio. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.vocabulary import ( + create_session, + kill_pane, + kill_session, + kill_window, + list_panes, + respawn_pane, + split_pane, +) +from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine +from libtmux.experimental.mcp.vocabulary._caller import ( + CallerContext, + caller_server_args, + engine_socket, + is_strict_caller, + socket_could_match, + socket_matches, +) +from libtmux.experimental.mcp.vocabulary._resolve import resolve_origin + +fastmcp = pytest.importorskip("fastmcp") +from fastmcp.exceptions import ToolError # noqa: E402 - after importorskip + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +# --------------------------------------------------------------------------- # +# CallerContext.from_env (pure) +# --------------------------------------------------------------------------- # +def test_from_env_inside_tmux() -> None: + """A full TMUX/TMUX_PANE pair parses into a populated context.""" + ctx = CallerContext.from_env( + {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"}, + ) + assert ctx.in_tmux + assert ctx.pane_id == "%3" + assert ctx.socket_path == "/tmp/tmux-1000/default" + assert ctx.server_pid == "42" + assert ctx.session_id == "2" + + +def test_from_env_outside_tmux() -> None: + """No TMUX/TMUX_PANE yields a context with in_tmux False.""" + assert CallerContext.from_env({}).in_tmux is False + + +def test_from_env_malformed_tmux() -> None: + """A malformed TMUX keeps the pane but leaves socket/pid/session None.""" + ctx = CallerContext.from_env({"TMUX_PANE": "%5", "TMUX": "garbage"}) + assert ctx.pane_id == "%5" + assert ctx.in_tmux is True + assert ctx.socket_path is None + + +# --------------------------------------------------------------------------- # +# Strict caller / engine socket (pure) +# --------------------------------------------------------------------------- # +def test_is_strict_caller_socket_scoped() -> None: + """is_caller requires pane equality and a socket match.""" + caller = CallerContext.from_env({"TMUX_PANE": "%3", "TMUX": "/tmp/a,1,2"}) + assert is_strict_caller("%3", None, caller) is True # default engine, same server + assert is_strict_caller("%3", "/tmp/a", caller) is True # same socket path + assert is_strict_caller("%3", "/tmp/b", caller) is False # cross-socket + assert is_strict_caller("%9", None, caller) is False # different pane + + +def test_is_strict_caller_outside_tmux() -> None: + """Nothing is the caller when the server is not inside tmux.""" + assert is_strict_caller("%1", None, CallerContext.from_env({})) is False + + +def test_engine_socket_parses_server_args() -> None: + """engine_socket reads -L name / -S path, else None for the default socket.""" + + class Named: + server_args = ("-Lwork",) + + class Pathed: + server_args = ("-S/tmp/x",) + + class Default: + server_args = () + + assert engine_socket(Named()) == "work" + assert engine_socket(Pathed()) == "/tmp/x" + assert engine_socket(Default()) is None + + +# --------------------------------------------------------------------------- # +# Surfacing via the server (in-process client) +# --------------------------------------------------------------------------- # +def test_get_caller_context_tool(monkeypatch: pytest.MonkeyPatch) -> None: + """get_caller_context returns the context read from the server's env.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.setenv("TMUX_PANE", "%7") + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/sock,1,3") + server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return (await client.call_tool("get_caller_context", {})).data + + data = asyncio.run(main()) + assert data.pane_id == "%7" + assert data.in_tmux is True + + +def test_instructions_include_caller(monkeypatch: pytest.MonkeyPatch) -> None: + """The instructions name the caller pane when the server is inside tmux.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.setenv("TMUX_PANE", "%7") + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/sock,1,3") + server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + assert "%7" in (server.instructions or "") + assert "is_caller" in (server.instructions or "") + + +def test_instructions_outside_tmux(monkeypatch: pytest.MonkeyPatch) -> None: + """The instructions say so when the server is not inside tmux.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("TMUX_PANE", raising=False) + # Disable the /proc parent walk so a test runner inside tmux is not discovered. + monkeypatch.setenv("LIBTMUX_MCP_DISCOVER", "0") + server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + assert "not running inside a tmux pane" in (server.instructions or "") + + +def test_is_caller_row_flag_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """list_panes flags the caller's own pane when env points at it.""" + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="callerlive") + try: + pane = created.first_pane_id + assert pane is not None + real_socket = session.server.cmd( + "display-message", "-p", "#{socket_path}" + ).stdout[0] + monkeypatch.setenv("TMUX_PANE", pane) + monkeypatch.setenv("TMUX", f"{real_socket},0,0") + flagged = [ + row["pane_id"] + for row in list_panes(engine).rows + if row.get("is_caller") == "1" + ] + assert pane in flagged + finally: + # Clear the simulated caller env so the self-kill guard does not refuse + # to tear down the session we pointed it at. + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + kill_session(engine, created.session_id) + + +# --------------------------------------------------------------------------- # +# resolve_origin caller-default is socket-scoped (the behavioural path) +# --------------------------------------------------------------------------- # +def test_resolve_origin_same_server_uses_caller( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """resolve_origin trusts the caller pane when the engine shares its server.""" + monkeypatch.setenv("TMUX_PANE", "%3") + monkeypatch.setenv("TMUX", "/tmp/a,1,2") + engine = SyncToAsyncEngine(ConcreteEngine()) # default socket -> ambient server + + async def main() -> str: + return await resolve_origin(engine, None, None) + + assert asyncio.run(main()) == "%3" + + +def test_resolve_origin_cross_server_requires_explicit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """resolve_origin requires an explicit origin for a cross-server caller.""" + monkeypatch.setenv("TMUX_PANE", "%3") + monkeypatch.setenv("TMUX", "/tmp/a,1,2") + + class CrossServer(SyncToAsyncEngine): + server_args = ("-S", "/tmp/b") # a different socket than the caller's + + engine = CrossServer(ConcreteEngine()) + + async def main() -> str: + return await resolve_origin(engine, None, None) + + # Cross-server: the env %3 is refused; the caller must name an origin. + with pytest.raises(ToolError, match="explicit origin"): + asyncio.run(main()) + + +# --------------------------------------------------------------------------- # +# CallerContext.discover -- precedence + injectable /proc parent walk +# --------------------------------------------------------------------------- # +def test_discover_process_env_wins() -> None: + """The server's own env beats every other source.""" + ctx = CallerContext.discover( + environ={"TMUX_PANE": "%1", "TMUX": "/s,1,2"}, is_linux=True + ) + assert ctx.source == "process-env" + assert ctx.pane_id == "%1" + + +def test_discover_override_beats_walk() -> None: + """LIBTMUX_MCP_CALLER_PANE is the trusted override (no /proc walk).""" + ctx = CallerContext.discover( + environ={"LIBTMUX_MCP_CALLER_PANE": "%5", "LIBTMUX_MCP_CALLER_TMUX": "/s,1,2"}, + is_linux=True, + ) + assert ctx.source == "explicit-override" + assert (ctx.pane_id, ctx.socket_path) == ("%5", "/s") + + +def test_discover_parent_walk_recovers_stripped_env() -> None: + """A stripped child recovers TMUX from a same-uid ancestor.""" + fake_env = {10: {}, 20: {"TMUX_PANE": "%9", "TMUX": "/tmp/sock,7,3"}} + fake_ppid = {10: 20, 20: 1} + ctx = CallerContext.discover( + environ={}, + read_env=fake_env.get, + read_ppid=fake_ppid.get, + read_uid=lambda _pid: 1000, + self_pid=10, + self_uid=1000, + is_linux=True, + ) + assert ctx.source == "parent-walk" + assert (ctx.pane_id, ctx.socket_path) == ("%9", "/tmp/sock") + + +def test_discover_refuses_foreign_uid() -> None: + """The walk stops at a differently-owned ancestor (no env read).""" + fake_env = {10: {}, 20: {"TMUX_PANE": "%9", "TMUX": "/s,1,2"}} + fake_ppid = {10: 20, 20: 1} + ctx = CallerContext.discover( + environ={}, + read_env=fake_env.get, + read_ppid=fake_ppid.get, + read_uid=lambda _pid: 99999, + self_pid=10, + self_uid=1000, + is_linux=True, + ) + assert ctx.source == "none" + + +def test_discover_off_linux() -> None: + """No /proc means no walk (fail closed to source='none').""" + assert CallerContext.discover(environ={}, is_linux=False).source == "none" + + +def test_discover_disabled_by_env() -> None: + """LIBTMUX_MCP_DISCOVER=0 disables the parent walk.""" + ctx = CallerContext.discover(environ={"LIBTMUX_MCP_DISCOVER": "0"}, is_linux=True) + assert ctx.source == "none" + + +def test_discover_fails_closed_on_reader_failure() -> None: + """A reader returning None mid-walk degrades to source='none'.""" + ctx = CallerContext.discover( + environ={}, + read_env=lambda _pid: None, + read_ppid=lambda _pid: 2, + read_uid=lambda _pid: 1000, + self_pid=1, + self_uid=1000, + is_linux=True, + ) + assert ctx.source == "none" + + +def test_caller_server_args_binds_caller_socket() -> None: + """The binding decision yields -S only for a discovered, non-overridden socket.""" + ctx = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/sock,1,2"}) + assert caller_server_args(ctx, explicit=False) == ("-S", "/sock") + assert caller_server_args(ctx, explicit=True) == () + assert caller_server_args(CallerContext.from_env({}), explicit=False) == () + + +# --------------------------------------------------------------------------- # +# Self-kill guards +# --------------------------------------------------------------------------- # +def test_kill_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> None: + """kill_pane refuses the pane running this MCP server.""" + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + with pytest.raises(ToolError, match="this MCP server"): + kill_pane(ConcreteEngine(), "%9") + + +def test_respawn_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> None: + """respawn_pane (which destroys the process) refuses the caller's pane.""" + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + with pytest.raises(ToolError, match="this MCP server"): + respawn_pane(ConcreteEngine(), "%9") + + +def test_kill_pane_allows_other_pane(monkeypatch: pytest.MonkeyPatch) -> None: + """A different pane is not the caller, so it is not refused.""" + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + assert kill_pane(ConcreteEngine(), "%1") is None + + +def test_self_kill_refusals_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Killing the caller's own pane/window/session is refused on a real server.""" + engine = SubprocessEngine.for_server(session.server) + real_socket = session.server.cmd("display-message", "-p", "#{socket_path}").stdout[ + 0 + ] + created = create_session(engine, name="selfkill") + try: + pane = created.first_pane_id + assert pane is not None + monkeypatch.setenv("TMUX_PANE", pane) + monkeypatch.setenv("TMUX", f"{real_socket},0,0") + with pytest.raises(ToolError, match="this MCP server"): + kill_pane(engine, pane) + with pytest.raises(ToolError, match="this MCP server"): + kill_window(engine, created.first_window_id or "") + with pytest.raises(ToolError, match="this MCP server"): + kill_session(engine, created.session_id) + finally: + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + kill_session(engine, created.session_id) + + +# --------------------------------------------------------------------------- # +# Review fixes: ambient-engine scoping (S1) + per-op guard (S2) +# --------------------------------------------------------------------------- # +def test_ambient_engine_matches_only_process_env_caller() -> None: + """An unbound engine is the caller's server only for a process-env caller.""" + proc = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/s,1,2"}) + walked = dataclasses.replace(proc, source="parent-walk") + assert socket_could_match(None, proc) is True + assert socket_could_match(None, walked) is False + assert socket_matches(None, proc) is True + assert socket_matches(None, walked) is False + + +def test_op_kill_pane_is_guarded(monkeypatch: pytest.MonkeyPatch) -> None: + """The per-op kill surface is self-kill-guarded too (no bypass).""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + server = build_async_server( + SyncToAsyncEngine(ConcreteEngine()), events="off", expose_operations=True + ) + + async def main() -> None: + async with fastmcp.Client(server) as client: + await client.call_tool("op_kill_pane", {"target": "%9"}) + + with pytest.raises(ToolError, match="this MCP server"): + asyncio.run(main()) + + +# --------------------------------------------------------------------------- # +# Deferrals: authoritative socket (1) + others=True per-op guard (2) +# --------------------------------------------------------------------------- # +def test_conservative_socket_prefers_explicit_path() -> None: + """An explicit -S path is authoritative as-is; no tmux query is issued.""" + from libtmux.experimental.mcp.vocabulary._resolve import conservative_socket + + class Pathed(SyncToAsyncEngine): + server_args = ("-S", "/tmp/explicit-socket") + + async def main() -> str | None: + return await conservative_socket(Pathed(ConcreteEngine()), None) + + assert asyncio.run(main()) == "/tmp/explicit-socket" + + +def test_kill_pane_others_refuses_caller_sibling_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """kill_pane(others=True) refuses when the caller is a sibling of the target.""" + engine = SubprocessEngine.for_server(session.server) + real_socket = session.server.cmd("display-message", "-p", "#{socket_path}").stdout[ + 0 + ] + created = create_session(engine, name="killothers") + try: + pane_a = created.first_pane_id + assert pane_a is not None + pane_b = split_pane(engine, pane_a, horizontal=True).pane_id + monkeypatch.setenv("TMUX_PANE", pane_b) # caller is sibling B + monkeypatch.setenv("TMUX", f"{real_socket},0,0") + with pytest.raises(ToolError, match="other panes"): + kill_pane(engine, pane_a, others=True) + finally: + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + kill_session(engine, created.session_id) + + +def test_op_kill_pane_others_is_guarded_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The per-op kill surface routes others=True to the sibling guard too.""" + from libtmux.experimental.engines import AsyncSubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + sync_engine = SubprocessEngine.for_server(session.server) + real_socket = session.server.cmd("display-message", "-p", "#{socket_path}").stdout[ + 0 + ] + created = create_session(sync_engine, name="opkillothers") + try: + pane_a = created.first_pane_id + assert pane_a is not None + pane_b = split_pane(sync_engine, pane_a, horizontal=True).pane_id + monkeypatch.setenv("TMUX_PANE", pane_b) + monkeypatch.setenv("TMUX", f"{real_socket},0,0") + server = build_async_server( + AsyncSubprocessEngine.for_server(session.server), + events="off", + expose_operations=True, + ) + + async def main() -> None: + async with fastmcp.Client(server) as client: + await client.call_tool( + "op_kill_pane", {"target": pane_a, "others": True} + ) + + with pytest.raises(ToolError, match="other panes"): + asyncio.run(main()) + finally: + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + kill_session(sync_engine, created.session_id) diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py new file mode 100644 index 000000000..079fbe9c9 --- /dev/null +++ b/tests/experimental/mcp/test_events.py @@ -0,0 +1,467 @@ +"""The live event stream tools -- push, pull, and the registration gate. + +Driven offline against a fake engine that yields a fixed notification sequence, +so the push/pull mechanics are exercised without a real tmux ``-C`` connection. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines.async_control_mode import ControlNotification +from libtmux.experimental.engines.base import CommandResult + +fastmcp = pytest.importorskip("fastmcp") + +if t.TYPE_CHECKING: + from collections.abc import AsyncIterator, Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class FakeStreamEngine: + """An async engine that replays a fixed notification stream.""" + + def __init__(self, raw: tuple[bytes, ...]) -> None: + self._raw = raw + + async def run(self, request: CommandRequest) -> CommandResult: + """Acknowledge any command.""" + return CommandResult(cmd=("tmux", *request.args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Acknowledge a batch of commands.""" + return [await self.run(r) for r in requests] + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Yield the fixed notification sequence.""" + for raw in self._raw: + yield ControlNotification.parse(raw) + + +_STREAM = (b"%window-add @3", b"%output %1 hi", b"%window-close @3") +_MON_STREAM = (b"%output %1 a b", b"%output %1 c", b"%window-add @9") + + +class InstrumentedEngine: + """A fake stream engine that records commands and scripts a few responses. + + Records every ``run`` argv in ``calls`` (so attach idempotence is assertable), + exposes a settable ``dropped_notifications`` counter, can inject an + ``attach-session`` failure, and can return a canned ``display-message`` line + for the done-heuristics format. + """ + + def __init__( + self, + raw: tuple[bytes, ...] = (), + *, + attach_returncode: int = 0, + done_line: str | None = None, + dropped_after: int = 0, + ) -> None: + self._raw = raw + self.calls: list[tuple[str, ...]] = [] + self.dropped_notifications = 0 + self._attached_session: str | None = None + self._attach_returncode = attach_returncode + self._done_line = done_line + self._dropped_after = dropped_after + + async def run(self, request: CommandRequest) -> CommandResult: + """Record the command and return a scripted result.""" + args = tuple(request.args) + self.calls.append(args) + if args and args[0] == "attach-session": + stderr = () if self._attach_returncode == 0 else ("can't find session",) + return CommandResult( + cmd=("tmux", *args), + returncode=self._attach_returncode, + stderr=stderr, + ) + if args and args[0] == "display-message": + fmt = args[-1] + if "pane_dead" in fmt and self._done_line is not None: + return CommandResult( + cmd=("tmux", *args), + returncode=0, + stdout=(self._done_line,), + ) + if "session_id" in fmt: + return CommandResult(cmd=("tmux", *args), returncode=0, stdout=("$1",)) + return CommandResult(cmd=("tmux", *args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Acknowledge a batch of commands.""" + return [await self.run(r) for r in requests] + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Yield the fixed notification sequence, then bump the drop counter.""" + for raw in self._raw: + yield ControlNotification.parse(raw) + self.dropped_notifications += self._dropped_after + + +def _tool_names(server: t.Any) -> set[str]: + """Return the visible tool names of *server* (via an in-process client).""" + + async def main() -> set[str]: + async with fastmcp.Client(server) as client: + return {tool.name for tool in await client.list_tools()} + + return asyncio.run(main()) + + +def test_push_collects_filtered_events() -> None: + """watch_events streams and returns only the requested notification kinds.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> dict[str, t.Any]: + async with fastmcp.Client(server) as client: + result = await client.call_tool( + "watch_events", + { + "kinds": ["window-add", "window-close"], + "max_events": 2, + "timeout": 2.0, + }, + ) + return t.cast("dict[str, t.Any]", result.data) + + data = asyncio.run(main()) + assert data["count"] == 2 + assert [event["kind"] for event in data["events"]] == ["window-add", "window-close"] + + +def test_pull_buffers_events() -> None: + """poll_events drains the background ring buffer with a cursor.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="pull", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> dict[str, t.Any]: + async with fastmcp.Client(server) as client: + await client.call_tool("poll_events", {"since": 0}) # start the drainer + await asyncio.sleep(0.05) + result = await client.call_tool("poll_events", {"since": 0}) + return t.cast("dict[str, t.Any]", result.data) + + data = asyncio.run(main()) + assert len(data["events"]) == 3 + assert data["cursor"] == 3 + + +def test_both_registers_push_and_pull() -> None: + """events='both' exposes both mechanisms.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="both", + include_operations=False, + include_plan_tools=False, + ) + names = _tool_names(server) + assert {"watch_events", "poll_events"} <= names + + +def test_monitor_registered_when_streaming() -> None: + """wait_for_output is exposed whenever the engine streams, in any event mode.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="pull", + include_operations=False, + include_plan_tools=False, + ) + assert "wait_for_output" in _tool_names(server) + + +def test_monitor_settles_on_stream_end() -> None: + """wait_for_output folds per-pane output and returns when the stream ends. + + The decoded chunks preserve internal whitespace (``a b`` + ``c`` -> ``a bc``), + locking out the ``" ".join`` reconstruction bug; the non-output frame is + filtered. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_MON_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + result = await client.call_tool("wait_for_output", {"target": "%1"}) + return result.data + + data = asyncio.run(main()) + assert data.pane_id == "%1" + assert data.reason == "stream_end" + assert data.captured_text == "a bc" + assert data.frame_count == 2 + assert data.truncated is False + assert data.snapshot_lines == [] + # The fake's pane id is unverifiable post-settle, so liveness reads unknown. + assert data.done.pane_dead is None + + +def test_monitor_snapshot_false_omits_grid() -> None: + """snapshot=False leaves snapshot_lines None and skips the capture.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + InstrumentedEngine(_MON_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + result = await client.call_tool( + "wait_for_output", + {"target": "%1", "snapshot": False}, + ) + return result.data + + data = asyncio.run(main()) + assert data.snapshot_lines is None + assert data.reason == "stream_end" + + +def test_monitor_reports_dropped_delta() -> None: + """The dropped field is the engine's overflow-counter delta during the watch.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + InstrumentedEngine(_MON_STREAM, dropped_after=5), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + result = await client.call_tool("wait_for_output", {"target": "%1"}) + return result.data + + data = asyncio.run(main()) + assert data.dropped == 5 + + +def test_monitor_stream_partials_pushes_each_chunk() -> None: + """stream_partials=True pushes each decoded chunk as an MCP log message.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + logged: list[t.Any] = [] + + async def log_handler(message: t.Any) -> None: + # ctx.info wraps the payload as {"msg": ..., "extra": ...}. + data = message.data + logged.append(data["msg"] if isinstance(data, dict) else data) + + server = build_async_server( + InstrumentedEngine(_MON_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> None: + async with fastmcp.Client(server, log_handler=log_handler) as client: + await client.call_tool( + "wait_for_output", + {"target": "%1", "stream_partials": True}, + ) + + asyncio.run(main()) + assert "a b" in logged + assert "c" in logged + + +def test_ensure_attached_raises_on_failed_attach() -> None: + """A failed attach raises and does not poison the per-engine cache.""" + from libtmux.experimental.mcp.events import _ensure_attached + + engine = InstrumentedEngine(attach_returncode=1) + with pytest.raises(RuntimeError, match="cannot watch"): + asyncio.run(_ensure_attached(engine, "$dead")) + assert getattr(engine, "_attached_session", None) is None + + +def test_ensure_attached_is_idempotent_per_session() -> None: + """Re-watching the same session attaches exactly once (no repeated redraw).""" + from libtmux.experimental.mcp.events import _ensure_attached + + engine = InstrumentedEngine() + + async def main() -> None: + await _ensure_attached(engine, "$1") + await _ensure_attached(engine, "$1") + + asyncio.run(main()) + attaches = [c for c in engine.calls if c and c[0] == "attach-session"] + assert len(attaches) == 1 + assert engine._attached_session == "$1" + + +def test_read_done_parses_display_message_fields() -> None: + """_read_done maps the tab-joined display-message into DoneMetadata.""" + from libtmux.experimental.mcp.events import _read_done + + done_line = "%1\t1\t137\tHUP\tbash\t3\t50\t1" # pane_id first + engine = InstrumentedEngine(done_line=done_line) + done = asyncio.run(_read_done(engine, "%1")) + assert done.pane_dead is True + assert done.pane_dead_status == 137 + assert done.pane_dead_signal == "HUP" + assert done.pane_current_command == "bash" + assert done.cursor_y == 3 + assert done.history_size == 50 + assert done.pane_in_mode is True + + +def test_subscribe_broadcasts_to_every_consumer() -> None: + """Concurrent subscribers each receive every notification (no frame stealing). + + A regression for the competing-consumer bug: a single shared queue handed each + item to exactly one waiter, so wait_for_output and watch_events/poll_events + stole each other's %output and the monitor could falsely report 'settled'. + """ + from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + engine = AsyncControlModeEngine() + + async def main() -> tuple[str, str]: + stream_a, stream_b = engine.subscribe(), engine.subscribe() + first = asyncio.ensure_future(stream_a.__anext__()) + second = asyncio.ensure_future(stream_b.__anext__()) + await asyncio.sleep(0) # let both register their queues at the first await + await asyncio.sleep(0) + engine._publish(b"%window-add @3") + notif_a, notif_b = await first, await second + # asyncio.run finalizes the suspended generators via shutdown_asyncgens. + return notif_a.raw, notif_b.raw + + raw_a, raw_b = asyncio.run(main()) + assert raw_a == "%window-add @3" + assert raw_b == "%window-add @3" # both, not split across consumers + + +def test_instructions_surface_wait_for_output() -> None: + """The server instructions name the run-a-command-and-wait workflow. + + If this fails, the discoverable wording drifted -- update BOTH the instruction + text in fastmcp_adapter.py AND these assertions intentionally. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + text = server.instructions or "" + assert "wait_for_output" in text # the tool by name + assert "send_input" in text # the workflow pair + assert "completion" in text # the run-a-command-and-wait intent + assert "pytest" in text # the long-running / test use case + assert "sleep + capture_pane" in text # the anti-polling steer + assert "Settled" in text # settled-is-not-success caveat + + +def test_instructions_omit_wait_for_output_without_streaming() -> None: + """events='off' (no wait_for_output tool) drops the live-output guidance. + + The instructions must not name a tool the server did not register. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="off", + include_operations=False, + include_plan_tools=False, + ) + text = server.instructions or "" + assert "wait_for_output" not in text + assert "watch_events" not in text + + +def test_wait_for_output_metadata_is_discoverable() -> None: + """wait_for_output's description + per-param schema carry the search vocabulary. + + If this fails, the discoverable wording drifted -- update BOTH the description / + docstring in events.py AND these assertions intentionally. The per-param + descriptions also prove FastMCP parsed the NumPy ``Parameters`` section. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return {tool.name: tool for tool in await client.list_tools()} + + by_name = asyncio.run(main()) + tool = by_name["wait_for_output"] + description = tool.description or "" + assert "wait" in description + assert "finish" in description + assert "completion" in description + assert "sleep + capture_pane" in description + + props = tool.inputSchema["properties"] + for param in ("target", "settle_ms", "timeout", "max_bytes", "stream_partials"): + assert props[param].get("description"), f"{param} missing param description" + assert "idle" in props["settle_ms"]["description"].lower() + + +def test_no_event_tools_without_a_stream() -> None: + """A non-streaming engine registers no event tools, even when asked.""" + from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + + server = build_async_server( + SyncToAsyncEngine(ConcreteEngine()), + events="both", + include_operations=False, + include_plan_tools=False, + ) + names = _tool_names(server) + assert "watch_events" not in names + assert "poll_events" not in names + assert "wait_for_output" not in names diff --git a/tests/experimental/mcp/test_fastmcp_adapter.py b/tests/experimental/mcp/test_fastmcp_adapter.py new file mode 100644 index 000000000..80dc91278 --- /dev/null +++ b/tests/experimental/mcp/test_fastmcp_adapter.py @@ -0,0 +1,228 @@ +"""The optional fastmcp adapter on a real FastMCP server (in-process). + +Proves the framework-agnostic projection actually drives fastmcp: the curated +vocabulary registers as typed tools (engine bound out of the schema, safety -> +annotations), and an in-process client can list and call them -- offline against +the ``ConcreteEngine`` and live against a real tmux server. Skipped entirely when +the ``mcp`` extra (fastmcp) is not installed. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.fastmcp_adapter import build_server +from libtmux.experimental.ops import NewSession +from libtmux.experimental.ops.serialize import operation_to_dict + +fastmcp = pytest.importorskip("fastmcp") + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_adapter_registers_typed_tools() -> None: + """The curated vocabulary appears as typed tools with safety annotations.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + tools = asyncio.run(main()) + by_name = {tool.name: tool for tool in tools} + assert { + "create_session", + "create_window", + "split_pane", + "send_input", + "capture_pane", + "list_sessions", + "kill_session", + } <= set(by_name) + + # safety tier -> ToolAnnotations + assert by_name["capture_pane"].annotations.readOnlyHint is True + assert by_name["kill_session"].annotations.destructiveHint is True + assert by_name["create_session"].annotations.readOnlyHint is False + + # the engine is injected, not an agent-facing parameter + properties = by_name["create_session"].inputSchema.get("properties", {}) + assert "engine" not in properties + assert "name" in properties + + +def test_adapter_calls_tool_offline() -> None: + """Calling a tool through the in-process client returns structured output.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.call_tool("create_session", {"name": "dev"}) + + result = asyncio.run(main()) + payload = result.structured_content or {} + assert payload.get("session_id") == "$1" + assert payload.get("first_pane_id") == "%1" + + +def test_adapter_live(session: Session) -> None: + """Drive a real tmux server through fastmcp tools end to end.""" + server = session.server + mcp = build_server(SubprocessEngine.for_server(server)) + + async def main() -> str | None: + async with fastmcp.Client(mcp) as client: + created = await client.call_tool("create_session", {"name": "fastmcp-live"}) + session_id = (created.structured_content or {}).get("session_id") + await client.call_tool( + "split_pane", + {"target": session_id, "horizontal": True}, + ) + await client.call_tool("kill_session", {"target": "fastmcp-live"}) + return session_id + + session_id = asyncio.run(main()) + assert session_id is not None + assert session_id.startswith("$") + assert not server.sessions.filter(session_name="fastmcp-live") + + +def test_adapter_operations_hidden_by_default() -> None: + """Per-operation tools are registered but hidden; plan tools stay visible.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + names = {tool.name for tool in asyncio.run(main())} + assert not any(name.startswith("op_") for name in names) + assert { + "preview_plan", + "execute_plan", + "result_schema", + "build_workspace", + } <= names + + +def test_adapter_exposes_per_op_tools() -> None: + """``expose_operations`` reveals one typed ``op_`` per operation.""" + server = build_server(ConcreteEngine(), expose_operations=True) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + by_name = {tool.name: tool for tool in asyncio.run(main())} + assert "op_split_window" in by_name + assert "op_new_session" in by_name + + # the target the registry omits is re-injected into the per-op schema + properties = by_name["op_split_window"].inputSchema.get("properties", {}) + assert "target" in properties + assert "horizontal" in properties + + # safety tier -> annotations + assert by_name["op_kill_session"].annotations.destructiveHint is True + assert by_name["op_capture_pane"].annotations.readOnlyHint is True + + +def test_adapter_per_op_call_offline() -> None: + """A per-op tool builds + runs its operation, returning the serialized result.""" + server = build_server(ConcreteEngine(), expose_operations=True) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.call_tool("op_new_session", {"session_name": "raw"}) + + payload = asyncio.run(main()).structured_content or {} + assert payload["operation"]["kind"] == "new_session" + assert payload["new_id"] == "$1" + + +def test_adapter_plan_tools_offline() -> None: + """preview/execute/result_schema drive a serialized plan with forward refs.""" + server = build_server(ConcreteEngine()) + operations = [operation_to_dict(NewSession(session_name="dev", capture_panes=True))] + + async def main() -> tuple[t.Any, t.Any, t.Any]: + async with fastmcp.Client(server) as client: + preview = await client.call_tool("preview_plan", {"operations": operations}) + outcome = await client.call_tool("execute_plan", {"operations": operations}) + schema = await client.call_tool("result_schema", {"kind": "new_session"}) + return preview, outcome, schema + + preview, outcome, schema = asyncio.run(main()) + assert preview.structured_content["ok"] is True + assert outcome.structured_content["ok"] is True + # the new session's captured sub-ids surface as forward-ref bindings + assert outcome.structured_content["bindings"]["0"] == "$1" + assert outcome.structured_content["bindings"]["0:pane"] == "%1" + assert "first_pane_id" in schema.structured_content["binding_fields"] + + +def test_adapter_build_workspace_offline() -> None: + """The workspace tool builds a declarative spec in one call (preflight off).""" + server = build_server(ConcreteEngine()) + spec = { + "session_name": "ws", + "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], + } + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.call_tool( + "build_workspace", + {"spec": spec, "preflight": False}, + ) + + payload = asyncio.run(main()).structured_content or {} + assert payload["ok"] is True + + +def test_default_server_builds() -> None: + """The packaged ``default_server`` factory exposes the curated + plan tools.""" + from libtmux.experimental.mcp import default_server + + server = default_server() + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + names = {tool.name for tool in asyncio.run(main())} + assert "create_session" in names + assert "execute_plan" in names + + +def test_main_help_exits() -> None: + """The console-script entry parses ``--help`` and exits cleanly.""" + from libtmux.experimental.mcp import main + + with pytest.raises(SystemExit) as excinfo: + main(["--help"]) + assert excinfo.value.code == 0 + + +def test_adapter_plan_live(session: Session) -> None: + """Execute a serialized plan over real tmux through the execute_plan tool.""" + server = session.server + mcp = build_server(SubprocessEngine.for_server(server)) + operations = [ + operation_to_dict(NewSession(session_name="plan-live", capture_panes=True)), + ] + + async def main() -> t.Any: + async with fastmcp.Client(mcp) as client: + return await client.call_tool("execute_plan", {"operations": operations}) + + outcome = asyncio.run(main()).structured_content + assert outcome["ok"] is True + assert outcome["bindings"]["0"].startswith("$") + assert server.sessions.filter(session_name="plan-live") + server.cmd("kill-session", "-t", "plan-live") diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py new file mode 100644 index 000000000..237b6d0d6 --- /dev/null +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -0,0 +1,132 @@ +"""The framework-agnostic MCP projection tier (no fastmcp required). + +Exercises descriptor generation from the operation registry, agent target +resolution, plan preview/execute with forward-ref bindings, result-schema +introspection, and the build_workspace tool -- all against the in-memory +``ConcreteEngine`` so the projection is provably correct offline. +""" + +from __future__ import annotations + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.mcp import ( + OperationToolRegistry, + build_workspace, + execute_plan, + preview_plan, + resolve_target, + result_schema, +) +from libtmux.experimental.ops import ( + LazyPlan, + NewSession, + SendKeys, + SplitWindow, + registry, +) +from libtmux.experimental.ops._types import NameRef, PaneId, SessionId, WindowId +from libtmux.experimental.ops.serialize import bindings_from_dict, bindings_to_dict + + +def test_every_operation_has_a_descriptor() -> None: + """The registry projects one valid descriptor per registered operation kind.""" + reg = OperationToolRegistry() + descriptors = reg.descriptors() + assert {d.name for d in descriptors} == set(registry.kinds()) + for d in descriptors: + schema = d.input_schema() + assert schema["type"] == "object" + assert d.safety in {"readonly", "mutating", "destructive"} + + +def test_split_window_descriptor_shape() -> None: + """A per-op descriptor carries typed params, scope, safety, and annotations.""" + desc = OperationToolRegistry().descriptor("split_window") + assert desc.name == "split_window" + assert desc.scope == "window" + assert desc.safety == "mutating" + assert desc.annotations == {"readOnlyHint": False} + assert desc.result_type == "SplitWindowResult" + assert desc.params["horizontal"].origin == "bool" + assert desc.params["horizontal"].is_required is False + + +def test_readonly_op_annotation() -> None: + """A readonly operation projects a readOnlyHint annotation + tag.""" + desc = OperationToolRegistry().descriptor("has_session") + assert desc.annotations == {"readOnlyHint": True} + assert "readonly" in desc.tags + + +def test_descriptor_build_resolves_targets() -> None: + """ToolDescriptor.build turns agent params into a typed operation.""" + desc = OperationToolRegistry().descriptor("split_window") + op = desc.build(target="@1", horizontal=True) + assert isinstance(op, SplitWindow) + assert op.target == WindowId("@1") + assert op.render() == ("split-window", "-t", "@1", "-h", "-P", "-F", "#{pane_id}") + + +def test_resolve_target_forms() -> None: + """resolve_target coerces every supported spec into a typed Target.""" + assert resolve_target("%1") == PaneId("%1") + assert resolve_target("@2") == WindowId("@2") + assert resolve_target("$0") == SessionId("$0") + assert resolve_target("work") == NameRef("work") + assert resolve_target({"type": "PaneId", "value": "%3"}) == PaneId("%3") + assert resolve_target(PaneId("%4")) == PaneId("%4") + assert resolve_target(None) is None + + +def test_bindings_round_trip() -> None: + """Plan bindings (incl. sub-ref tuple keys) survive a JSON round-trip.""" + original: dict[int | tuple[int, str], str] = {0: "$1", (0, "pane"): "%2", 1: "@3"} + assert bindings_from_dict(bindings_to_dict(original)) == original + + +def test_preview_plan_marks_unresolved_forward_refs() -> None: + """preview_plan renders a pure dry-run; forward-ref steps render as None.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + preview = preview_plan(plan) + assert preview.argv[0] is not None + assert preview.argv[1] is None # SendKeys targets the not-yet-created pane + assert preview.ok is False + + +def test_execute_plan_returns_bindings() -> None: + """execute_plan resolves forward refs and returns a JSON bindings map.""" + plan = LazyPlan() + session = plan.add(NewSession(session_name="dev", capture_panes=True)) + plan.add(SendKeys(target=session.pane, keys="vim", enter=True)) + outcome = execute_plan(plan, ConcreteEngine()) + assert outcome.ok + assert outcome.bindings["0"].startswith("$") + assert outcome.bindings["0:pane"].startswith("%") + assert outcome.results[1]["argv"][0] == "send-keys" + + +def test_result_schema_introspection() -> None: + """result_schema reports the id fields an agent can bind downstream.""" + split = result_schema(OperationToolRegistry(), "split_window") + assert split.result_type == "SplitWindowResult" + assert "new_pane_id" in split.binding_fields + + session = result_schema(OperationToolRegistry(), "new_session") + assert "first_pane_id" in session.binding_fields + assert "first_window_id" in session.binding_fields + + +def test_build_workspace_tool_offline() -> None: + """build_workspace runs the declarative tier as one tool call (offline).""" + outcome = build_workspace( + { + "session_name": "dev", + "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], + }, + ConcreteEngine(), + preflight=False, + ) + assert outcome.ok + assert outcome.bindings["0"].startswith("$") diff --git a/tests/experimental/mcp/test_monitor_live.py b/tests/experimental/mcp/test_monitor_live.py new file mode 100644 index 000000000..4f55d5805 --- /dev/null +++ b/tests/experimental/mcp/test_monitor_live.py @@ -0,0 +1,73 @@ +"""End-to-end ``wait_for_output`` against a real tmux control-mode engine. + +Unlike the offline event tests, this drives the monitor through an in-process +FastMCP client over a live ``tmux -C`` connection: a real pane produces output, +and the tool folds the genuine ``%output`` firehose (octal-decoded) until the +pane goes quiet. Proves the needle-free settle path works against real tmux, not +just a scripted stream. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines.base import CommandRequest + +fastmcp = pytest.importorskip("fastmcp") + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_wait_for_output_captures_real_output(session: Session) -> None: + """The monitor folds a real pane's output and settles when it goes quiet.""" + from libtmux.experimental.engines import AsyncControlModeEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = session.server + pane = session.active_window.active_pane + assert pane is not None + pane_id = pane.pane_id + assert pane_id is not None + + async def main() -> t.Any: + async with AsyncControlModeEngine.for_server(server) as engine: + mcp = build_async_server( + engine, + events="push", + include_operations=False, + include_plan_tools=False, + ) + async with fastmcp.Client(mcp) as client: + + async def produce() -> None: + # Let the monitor subscribe first, then make the pane emit. + await asyncio.sleep(0.3) + await engine.run( + CommandRequest.from_args( + "send-keys", + "-t", + pane_id, + "echo MONITOR_OK", + "Enter", + ), + ) + + producer = asyncio.ensure_future(produce()) + try: + result = await client.call_tool( + "wait_for_output", + {"target": pane_id, "settle_ms": 400, "timeout": 10.0}, + ) + finally: + await producer + return result.data + + data = asyncio.run(main()) + assert data.pane_id == pane_id + assert data.reason in ("settled", "byte_cap") + assert "MONITOR_OK" in data.captured_text + assert data.frame_count >= 1 diff --git a/tests/experimental/mcp/test_proc.py b/tests/experimental/mcp/test_proc.py new file mode 100644 index 000000000..8a0d73e71 --- /dev/null +++ b/tests/experimental/mcp/test_proc.py @@ -0,0 +1,43 @@ +"""The Linux ``/proc`` readers for caller discovery: byte parsing + fail-closed.""" + +from __future__ import annotations + +import os + +from libtmux.experimental.mcp.vocabulary._proc import ( + _ppid_from_stat, + read_proc_environ, + read_proc_ppid, + read_proc_uid, +) + + +def test_ppid_from_stat_survives_parens_in_comm() -> None: + """The ppid parse anchors on the last ')', so a paren-laden comm is fine.""" + assert _ppid_from_stat(b"1234 (tmux: serv (x)) S 99 1234 1234 0 -1") == 99 + + +def test_ppid_from_stat_garbage_is_none() -> None: + """Unparseable stat bytes yield None, never an exception.""" + assert _ppid_from_stat(b"nonsense") is None + + +def test_real_readers_match_os() -> None: + """The real /proc readers agree with os.getppid()/os.getuid() for self.""" + assert read_proc_ppid(os.getpid()) == os.getppid() + assert read_proc_uid(os.getpid()) == os.getuid() + + +def test_environ_reader_minimises_keys() -> None: + """The environ reader exposes only TMUX/TMUX_PANE (never other secrets).""" + env = read_proc_environ(os.getpid()) + assert env is not None + assert set(env) <= {"TMUX", "TMUX_PANE"} + + +def test_readers_fail_closed_on_bad_pid() -> None: + """An unreadable pid yields None from every reader (never raises).""" + bad = -1 + assert read_proc_environ(bad) is None + assert read_proc_ppid(bad) is None + assert read_proc_uid(bad) is None diff --git a/tests/experimental/mcp/test_relative_special_guard.py b/tests/experimental/mcp/test_relative_special_guard.py new file mode 100644 index 000000000..152b68a51 --- /dev/null +++ b/tests/experimental/mcp/test_relative_special_guard.py @@ -0,0 +1,119 @@ +"""The relative-special-target guard and the composed relative tools. + +``capture_pane`` / ``grep_pane`` / ``send_input`` must reject a directional +special target (``{up-of}`` …) with a hint -- those resolve against this MCP's +control client, not the caller. Anchor specials (``{marked}`` / ``{last}``) must +still pass through. The composed ``capture_relative_pane`` resolves the neighbour +to a concrete ``%N`` first (live). +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.vocabulary import ( + break_pane, + capture_pane, + capture_relative_pane, + create_session, + grep_pane, + join_pane, + kill_pane, + kill_session, + resize_pane, + respawn_pane, + select_pane, + send_input, + split_pane, + swap_pane, +) + +fastmcp = pytest.importorskip("fastmcp") +from fastmcp.exceptions import ToolError # noqa: E402 - after importorskip + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +@pytest.mark.parametrize("token", ["{up-of}", "{down-of}", "{left-of}", "{right-of}"]) +def test_grep_rejects_relative_special(token: str) -> None: + """grep_pane with a directional special target raises a targeted hint.""" + with pytest.raises(ToolError, match="control-mode client"): + grep_pane(ConcreteEngine(capture_lines=("x",)), token, "x") + + +def test_capture_rejects_relative_special() -> None: + """capture_pane rejects a directional special target.""" + with pytest.raises(ToolError, match="control-mode client"): + capture_pane(ConcreteEngine(), "{down-of}") + + +def test_send_rejects_relative_special() -> None: + """send_input rejects a directional special target.""" + with pytest.raises(ToolError, match="control-mode client"): + send_input(ConcreteEngine(), "{left-of}", "ls") + + +@pytest.mark.parametrize("token", ["{marked}", "{last}"]) +def test_anchor_specials_pass_through(token: str) -> None: + """Anchor special targets are not rejected (real tmux semantics).""" + engine = ConcreteEngine(capture_lines=("hi",)) + assert capture_pane(engine, token).lines == ("hi",) + + +@pytest.mark.parametrize( + "call", + [ + lambda e: kill_pane(e, "{up-of}"), + lambda e: resize_pane(e, "{up-of}", width=80), + lambda e: respawn_pane(e, "{up-of}"), + lambda e: swap_pane(e, "{up-of}", "%1"), + lambda e: swap_pane(e, "%1", "{up-of}"), + lambda e: join_pane(e, "{up-of}", "%1"), + lambda e: break_pane(e, "{up-of}"), + lambda e: select_pane(e, "{up-of}"), + ], + ids=[ + "kill", + "resize", + "respawn", + "swap_src", + "swap_dst", + "join", + "break", + "select", + ], +) +def test_mutating_tools_reject_relative_special(call: t.Any) -> None: + """Destructive/mutating pane tools reject a relative special target too.""" + with pytest.raises(ToolError, match="control-mode client"): + call(ConcreteEngine()) + + +def test_grep_pane_invalid_regex_hint() -> None: + """An invalid search regex is surfaced as a targeted hint, not a raw re.error.""" + with pytest.raises(ToolError, match="invalid search pattern"): + grep_pane(ConcreteEngine(capture_lines=("x",)), "%1", "[unclosed") + + +def test_capture_relative_pane_resolves_concrete_live(session: Session) -> None: + """capture_relative_pane resolves a neighbour to a concrete %N and captures it.""" + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="relcap") + try: + origin = created.first_pane_id + assert origin is not None + split_pane(engine, origin, horizontal=True) + captured = None + for direction in ("left", "right"): + try: + captured = capture_relative_pane(engine, direction, origin) + break + except ToolError: + continue # no neighbour that way; try the other side + assert captured is not None # resolved a concrete pane and captured it + finally: + kill_session(engine, created.session_id) diff --git a/tests/experimental/mcp/test_settle.py b/tests/experimental/mcp/test_settle.py new file mode 100644 index 000000000..9c93907fb --- /dev/null +++ b/tests/experimental/mcp/test_settle.py @@ -0,0 +1,162 @@ +"""The pure settle accumulator -- decoder, per-pane filter, and the fold. + +Driven offline with literal strings and fake async generators plus an injected +clock, so every stop reason (settled, byte_cap, time_cap, stream_end) and the +cancellation teardown are exercised deterministically without a real tmux ``-C`` +connection or ``pytest-asyncio``. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.mcp._settle import ( + accumulate_until_settle, + decode_output, + output_payload, +) + +if t.TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable + + +def test_decode_output_octal_and_passthrough() -> None: + """Octal escapes decode; an escaped backslash collapses; plain text is kept.""" + assert decode_output("a\\012b") == "a\nb" + assert decode_output("tab\\011x") == "tab\tx" + assert decode_output("a\\134b") == "a\\b" + assert decode_output("plain, spaces kept") == "plain, spaces kept" + assert decode_output("trailing\\") == "trailing\\" + + +def test_output_payload_preserves_internal_whitespace() -> None: + """The per-pane filter slices the data body without collapsing inner spaces.""" + assert output_payload("%output %1 a b", "%1") == "a b" + assert output_payload("%output %2 x", "%1") is None + assert output_payload("%window-add @3", "%1") is None + + +def test_accumulate_settles_on_idle() -> None: + """A pane that emits then goes quiet returns reason='settled'.""" + + async def quiet_after_two() -> AsyncGenerator[str, None]: + yield "hello " + yield "world" + await asyncio.Event().wait() + + out = asyncio.run( + accumulate_until_settle( + quiet_after_two(), + settle_ms=10, + timeout_ms=2000, + max_bytes=4096, + ), + ) + assert out.reason == "settled" + assert out.text == "hello world" + assert out.byte_count == 11 + assert out.frame_count == 2 + assert out.truncated is False + # On 'settled', idle_ms_observed is exactly the settle_ms threshold. + assert out.idle_ms_observed == 10 + + +def test_accumulate_byte_cap_keeps_tail() -> None: + """A flood past max_bytes truncates, preserving the tail.""" + + async def flood() -> AsyncGenerator[str, None]: + for _ in range(100): + yield "abcde" + + out = asyncio.run( + accumulate_until_settle( + flood(), + settle_ms=50, + timeout_ms=2000, + max_bytes=8, + ), + ) + assert out.reason == "byte_cap" + assert out.byte_count == 8 + assert out.truncated is True + assert out.text == "cdeabcde" # last 8 bytes of "abcdeabcde" + + +def test_accumulate_stream_end() -> None: + """An exhausted stream returns reason='stream_end'.""" + + async def two_then_done() -> AsyncGenerator[str, None]: + yield "a" + yield "b" + + out = asyncio.run( + accumulate_until_settle( + two_then_done(), + settle_ms=50, + timeout_ms=2000, + max_bytes=64, + ), + ) + assert out.reason == "stream_end" + assert out.text == "ab" + + +def test_accumulate_time_cap_with_scripted_clock() -> None: + """A slow-but-never-idle pane hits the wall-clock cap via the injected clock.""" + + def make_clock(step: float = 0.5) -> Callable[[], float]: + state = {"t": -step} + + def clock() -> float: + state["t"] += step + return state["t"] + + return clock + + async def forever() -> AsyncGenerator[str, None]: + while True: + yield "x" + + out = asyncio.run( + accumulate_until_settle( + forever(), + settle_ms=50, + timeout_ms=1000, + max_bytes=100000, + now=make_clock(), + ), + ) + assert out.reason == "time_cap" + assert out.frame_count >= 1 + + +def test_accumulate_closes_stream_on_cancel() -> None: + """Cancelling the fold closes the stream, so no consumer is leaked.""" + closed = {"value": False} + + async def blocking() -> AsyncGenerator[str, None]: + try: + yield "first" + await asyncio.Event().wait() + finally: + closed["value"] = True + + async def main() -> bool: + task = asyncio.ensure_future( + accumulate_until_settle( + blocking(), + settle_ms=10000, + timeout_ms=10000, + max_bytes=4096, + ), + ) + await asyncio.sleep(0.05) # let the fold park on the blocking stream + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + return closed["value"] + + assert asyncio.run(main()) is True diff --git a/tests/experimental/mcp/test_vocabulary.py b/tests/experimental/mcp/test_vocabulary.py new file mode 100644 index 000000000..790112953 --- /dev/null +++ b/tests/experimental/mcp/test_vocabulary.py @@ -0,0 +1,107 @@ +"""The curated core vocabulary -- intuitive named tmux tools. + +Pure tests run the vocabulary against the in-memory ``ConcreteEngine`` (no tmux); +a live test drives a real tmux server end to end (create -> window -> split -> +send -> capture -> rename -> kill) over the subprocess engine. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp import ( + capture_pane, + create_session, + create_window, + kill_session, + list_panes, + list_sessions, + list_windows, + rename_window, + send_input, + split_pane, +) +from libtmux.experimental.ops._types import SessionId +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from pathlib import Path + + from libtmux.session import Session + + +def test_create_session_returns_typed_result() -> None: + """create_session yields a typed result with the captured first pane id.""" + result = create_session(ConcreteEngine(), name="dev") + assert result.session_id == "$1" + assert result.name == "dev" + assert result.first_window_id == "@1" + assert result.first_pane_id == "%1" + + +def test_create_window_then_split() -> None: + """create_window captures a first pane id that split_pane can target.""" + engine = ConcreteEngine() + session = create_session(engine, name="dev") + window = create_window(engine, session.session_id, name="logs") + assert window.window_id.startswith("@") + assert window.first_pane_id is not None + pane = split_pane(engine, window.first_pane_id, horizontal=True) + assert pane.pane_id.startswith("%") + + +def test_send_input_is_fire_and_forget() -> None: + """send_input runs without returning a value (and without raising).""" + send_input(ConcreteEngine(), "%1", "echo hi", enter=True) + + +def test_capture_pane_returns_lines() -> None: + """capture_pane surfaces the pane's lines.""" + engine = ConcreteEngine(capture_lines=("line-1", "line-2")) + assert capture_pane(engine, "%1").lines == ("line-1", "line-2") + + +def test_list_tools_return_listings() -> None: + """The list_* tools return a Listing of format rows.""" + engine = ConcreteEngine() + assert isinstance(list_sessions(engine).rows, tuple) + assert isinstance(list_windows(engine).rows, tuple) + assert isinstance(list_panes(engine).rows, tuple) + + +def test_target_accepts_string_or_typed() -> None: + """A vocabulary target may be a string or an already-typed Target.""" + engine = ConcreteEngine() + assert create_window(engine, "$1").window_id.startswith("@") + assert create_window(engine, SessionId("$1")).window_id.startswith("@") + + +def test_vocabulary_live(session: Session, tmp_path: Path) -> None: + """Drive a real tmux server through the curated vocabulary end to end.""" + server = session.server + engine = SubprocessEngine.for_server(server) + + created = create_session(engine, name="vocab-live", start_directory=str(tmp_path)) + try: + assert server.sessions.filter(session_name="vocab-live") + assert created.first_pane_id is not None + + window = create_window(engine, created.session_id, name="extra") + assert window.first_pane_id is not None + pane = split_pane(engine, window.first_pane_id, horizontal=True) + send_input(engine, pane.pane_id, "echo VOCABMARK", enter=True) + + def _ran() -> bool: + live = server.panes.get(pane_id=pane.pane_id) + return live is not None and "VOCABMARK" in "\n".join(live.capture_pane()) + + assert retry_until(_ran, 5, raises=False) + + rename_window(engine, window.window_id, "renamed") + renamed = server.windows.get(window_id=window.window_id) + assert renamed is not None + assert renamed.window_name == "renamed" + finally: + kill_session(engine, created.session_id) + assert not server.sessions.filter(session_name="vocab-live") diff --git a/tests/experimental/mcp/test_vocabulary_extended.py b/tests/experimental/mcp/test_vocabulary_extended.py new file mode 100644 index 000000000..81929265c --- /dev/null +++ b/tests/experimental/mcp/test_vocabulary_extended.py @@ -0,0 +1,243 @@ +"""Extended vocabulary -- new verbs, conveniences, the async surface, the bridge. + +Pure tests run against the in-memory ``ConcreteEngine`` and the pure geometry +helpers (no tmux); live tests drive a real tmux server for the geometry-resolved +conveniences (``resolve_relative_pane`` / ``find_pane_by_position`` / directional +``select_pane``) that only mean something against a real layout. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.vocabulary import ( + PaneRef, + acreate_session, + agrep_pane, + capture_active_pane, + create_session, + grep_pane, + has_session, + resize_pane, + resolve_relative_pane, + run_tmux, + select_pane, +) +from libtmux.experimental.mcp.vocabulary._bridge import ( + SyncToAsyncEngine, + drive_sync, + synced, +) +from libtmux.experimental.mcp.vocabulary._geometry import ( + PaneBox, + corner_pane, + neighbor, + parse_boxes, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +# --------------------------------------------------------------------------- # +# Geometry helpers (pure) +# --------------------------------------------------------------------------- # +def _two_columns() -> list[PaneBox]: + """Build a left|right two-pane layout.""" + return parse_boxes( + [ + { + "pane_id": "%1", + "pane_left": "0", + "pane_top": "0", + "pane_right": "39", + "pane_bottom": "23", + "pane_at_left": "1", + "pane_at_top": "1", + "pane_at_bottom": "1", + "pane_at_right": "0", + }, + { + "pane_id": "%2", + "pane_left": "41", + "pane_top": "0", + "pane_right": "80", + "pane_bottom": "23", + "pane_at_left": "0", + "pane_at_top": "1", + "pane_at_bottom": "1", + "pane_at_right": "1", + }, + ], + ) + + +def test_neighbor_resolves_horizontal() -> None: + """The right neighbour of the left pane is the right pane, and vice versa.""" + boxes = _two_columns() + assert neighbor(boxes, "%1", "right") == "%2" + assert neighbor(boxes, "%2", "left") == "%1" + + +def test_neighbor_none_when_no_pane_that_way() -> None: + """A pane with no neighbour in a direction resolves to None.""" + boxes = _two_columns() + assert neighbor(boxes, "%1", "left") is None + assert neighbor(boxes, "%1", "up") is None + assert neighbor(boxes, "%9", "right") is None # unknown origin + + +def test_corner_pane_uses_edge_predicates() -> None: + """The corner finder composes the pane_at_* predicates.""" + boxes = _two_columns() + assert corner_pane(boxes, "top-left") == "%1" + assert corner_pane(boxes, "bottom-right") == "%2" + + +# --------------------------------------------------------------------------- # +# The sync bridge +# --------------------------------------------------------------------------- # +def test_synced_twin_runs_over_sync_engine() -> None: + """A synced twin drives its async source over a plain sync engine.""" + result = create_session(ConcreteEngine(), name="dev") # create_session is a twin + assert result.session_id == "$1" + + +def test_drive_sync_rejects_real_io() -> None: + """drive_sync refuses a coroutine that suspends on a real await.""" + + async def suspends() -> int: + await asyncio.sleep(0) # yields to the loop -- no loop here + return 1 + + with pytest.raises(RuntimeError, match="real I/O"): + drive_sync(suspends()) + + +def test_synced_preserves_callable() -> None: + """synced() yields a callable with the engine param retyped to sync.""" + + async def tool(engine: t.Any, value: int) -> int: + return value + + twin = synced(tool) + hints = t.get_type_hints(twin) + assert hints["engine"].__name__ == "TmuxEngine" + + +# --------------------------------------------------------------------------- # +# New verbs / conveniences (offline) +# --------------------------------------------------------------------------- # +def test_grep_pane_filters_lines() -> None: + """grep_pane returns only the captured lines matching the pattern.""" + engine = ConcreteEngine(capture_lines=("foo", "bar baz", "foobar")) + assert grep_pane(engine, "%1", "foo").lines == ("foo", "foobar") + + +def test_grep_pane_ignore_case() -> None: + """grep_pane honours the ignore_case flag.""" + engine = ConcreteEngine(capture_lines=("FOO", "bar")) + assert grep_pane(engine, "%1", "foo", ignore_case=True).lines == ("FOO",) + + +def test_capture_active_pane_needs_no_target() -> None: + """capture_active_pane captures with no explicit target.""" + engine = ConcreteEngine(capture_lines=("hello",)) + assert capture_active_pane(engine).lines == ("hello",) + + +def test_resize_and_run_tmux_offline() -> None: + """resize_pane is fire-and-forget; run_tmux returns a raw outcome.""" + engine = ConcreteEngine() + assert resize_pane(engine, "%1", width=80) is None + raw = run_tmux(engine, ["list-sessions"]) + assert raw.ok and raw.returncode == 0 + + +def test_has_session_returns_bool() -> None: + """has_session answers an existence query as a bool.""" + assert has_session(ConcreteEngine(), "$1") is True + + +def test_geometry_tools_return_paneref_offline() -> None: + """Geometry-resolved tools return a PaneRef even with nothing to resolve.""" + engine = ConcreteEngine() + assert isinstance(resolve_relative_pane(engine, "right", "%1"), PaneRef) + assert isinstance(select_pane(engine, "%1", direction="left"), PaneRef) + + +# --------------------------------------------------------------------------- # +# The async surface +# --------------------------------------------------------------------------- # +def test_async_surface_over_wrapped_engine() -> None: + """The a-prefixed tools run over an async engine (sync engine wrapped).""" + + async def main() -> tuple[str, tuple[str, ...]]: + engine = SyncToAsyncEngine(ConcreteEngine(capture_lines=("x", "y"))) + session = await acreate_session(engine, name="dev") + grep = await agrep_pane(engine, "%1", "x") + return session.session_id, grep.lines + + session_id, lines = asyncio.run(main()) + assert session_id == "$1" + assert lines == ("x",) + + +# --------------------------------------------------------------------------- # +# Live geometry conveniences +# --------------------------------------------------------------------------- # +def test_resolve_relative_pane_live(session: Session) -> None: + """resolve_relative_pane finds the adjacent pane in a real split layout.""" + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="reltest") + try: + origin = created.first_pane_id + assert origin is not None + other = split_pane_id(engine, origin) + # Exactly one of left/right of the origin is the new pane. + found = { + resolve_relative_pane(engine, "left", origin).pane_id, + resolve_relative_pane(engine, "right", origin).pane_id, + } + assert other in found + finally: + kill(engine, created.session_id) + + +def test_find_pane_by_position_live(session: Session) -> None: + """find_pane_by_position returns a real pane occupying the corner.""" + from libtmux.experimental.mcp.vocabulary import find_pane_by_position, list_panes + + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="corner") + try: + origin = created.first_pane_id + assert origin is not None + split_pane_id(engine, origin) + ids = { + row["pane_id"] + for row in list_panes(engine).rows + if row.get("session_id") == created.session_id + } + corner = find_pane_by_position(engine, "top-left", origin).pane_id + assert corner in ids + finally: + kill(engine, created.session_id) + + +def split_pane_id(engine: SubprocessEngine, target: str) -> str: + """Split *target* horizontally and return the new pane id (test helper).""" + from libtmux.experimental.mcp.vocabulary import split_pane + + return split_pane(engine, target, horizontal=True).pane_id + + +def kill(engine: SubprocessEngine, session_id: str) -> None: + """Kill a test session (helper).""" + from libtmux.experimental.mcp.vocabulary import kill_session + + kill_session(engine, session_id) diff --git a/tests/experimental/models/__init__.py b/tests/experimental/models/__init__.py new file mode 100644 index 000000000..eb117f032 --- /dev/null +++ b/tests/experimental/models/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.models.""" + +from __future__ import annotations diff --git a/tests/experimental/models/test_snapshots.py b/tests/experimental/models/test_snapshots.py new file mode 100644 index 000000000..61238b7b5 --- /dev/null +++ b/tests/experimental/models/test_snapshots.py @@ -0,0 +1,134 @@ +"""Tests for the pure object-graph snapshots.""" + +from __future__ import annotations + +import pytest + +from libtmux.experimental.models import ( + PaneSnapshot, + ServerSnapshot, + WindowSnapshot, +) +from libtmux.experimental.models.snapshots import _as_bool, _as_int + + +@pytest.mark.parametrize( + ("value", "expected"), + [("3", 3), ("0", 0), ("", None), (None, None), ("nope", None)], +) +def test_as_int(value: str | None, expected: int | None) -> None: + """Format values coerce to int or None.""" + assert _as_int(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [("1", True), ("0", False), ("", False), (None, False), ("on", True)], +) +def test_as_bool(value: str | None, expected: bool) -> None: + """Flag values coerce to bool.""" + assert _as_bool(value) is expected + + +def test_pane_from_format_typed_core() -> None: + """A pane snapshot exposes a typed core derived from the raw mapping.""" + pane = PaneSnapshot.from_format( + { + "pane_id": "%1", + "pane_index": "2", + "pane_active": "1", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "vim", + }, + ) + assert pane.pane_id == "%1" + assert pane.pane_index == 2 + assert pane.active is True + assert pane.width == 80 + assert pane.current_command == "vim" + + +def test_raw_fields_preserved() -> None: + """The full raw mapping is retained even for un-promoted fields.""" + pane = PaneSnapshot.from_format({"pane_id": "%1", "pane_tty": "/dev/pts/3"}) + assert pane.fields["pane_tty"] == "/dev/pts/3" + + +def test_window_from_format_has_empty_panes() -> None: + """A window built from a format mapping starts with no panes.""" + window = WindowSnapshot.from_format({"window_id": "@1", "window_name": "main"}) + assert window.panes == () + + +def test_from_pane_rows_builds_tree_in_order() -> None: + """Flat pane rows group into an ordered session/window/pane tree.""" + rows = [ + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%1", + }, + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%2", + }, + { + "session_id": "$0", + "session_name": "a", + "window_id": "@2", + "window_index": "1", + "pane_id": "%3", + }, + { + "session_id": "$1", + "session_name": "b", + "window_id": "@3", + "window_index": "0", + "pane_id": "%4", + }, + ] + server = ServerSnapshot.from_pane_rows(rows, socket_name="test") + + assert server.socket_name == "test" + assert [s.session_id for s in server.sessions] == ["$0", "$1"] + first = server.sessions[0] + assert first.name == "a" + assert [w.window_id for w in first.windows] == ["@1", "@2"] + assert [p.pane_id for p in first.windows[0].panes] == ["%1", "%2"] + assert [p.pane_id for p in first.windows[1].panes] == ["%3"] + assert server.sessions[1].windows[0].panes[0].pane_id == "%4" + + +def test_empty_rows_yield_empty_server() -> None: + """No rows produces a server snapshot with no sessions.""" + assert ServerSnapshot.from_pane_rows([]).sessions == () + + +def test_tree_round_trips_through_dict() -> None: + """A full server tree survives a to_dict / from_dict round-trip.""" + rows = [ + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%1", + "pane_active": "1", + }, + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%2", + "pane_active": "0", + }, + ] + server = ServerSnapshot.from_pane_rows(rows, socket_name="test") + assert ServerSnapshot.from_dict(server.to_dict()) == server diff --git a/tests/experimental/ops/__init__.py b/tests/experimental/ops/__init__.py new file mode 100644 index 000000000..ae42924a7 --- /dev/null +++ b/tests/experimental/ops/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.ops.""" + +from __future__ import annotations diff --git a/tests/experimental/ops/test_ack_ops.py b/tests/experimental/ops/test_ack_ops.py new file mode 100644 index 000000000..3e03d8b54 --- /dev/null +++ b/tests/experimental/ops/test_ack_ops.py @@ -0,0 +1,130 @@ +"""Tests for no-output operations and the AckResult type.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + KillPane, + KillWindow, + RenameWindow, + SelectLayout, + SendKeys, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import TmuxCommandError +from libtmux.experimental.ops.results import AckResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + + +@pytest.mark.parametrize( + ("operation", "expected_argv"), + [ + pytest.param( + RenameWindow(target=WindowId("@1"), name="build"), + ("rename-window", "-t", "@1", "build"), + id="rename_window", + ), + pytest.param( + KillWindow(target=WindowId("@1")), + ("kill-window", "-t", "@1"), + id="kill_window", + ), + pytest.param( + KillPane(target=PaneId("%1")), + ("kill-pane", "-t", "%1"), + id="kill_pane", + ), + pytest.param( + SendKeys(target=PaneId("%1"), keys="x"), + ("send-keys", "-t", "%1", "x"), + id="send_keys", + ), + pytest.param( + SelectLayout(target=WindowId("@1"), layout="tiled"), + ("select-layout", "-t", "@1", "tiled"), + id="select_layout", + ), + ], +) +def test_no_output_ops_return_ack( + operation: Operation[AckResult], + expected_argv: tuple[str, ...], +) -> None: + """No-output operations render correctly and yield an AckResult.""" + result = run(operation, ConcreteEngine()) + assert type(result) is AckResult + assert result.argv == expected_argv + assert result.ok + + +def test_ack_success_has_no_payload() -> None: + """A successful ack carries only status -- no extra fields beyond the base.""" + result = RenameWindow(target=WindowId("@1"), name="x").build_result(returncode=0) + assert isinstance(result, AckResult) + assert result.ok + assert result.stdout == () + + +def test_ack_failure_raises_on_demand() -> None: + """A no-output command can still fail; raise_for_status surfaces it.""" + result = KillWindow(target=WindowId("@9")).build_result( + returncode=1, + stderr=("can't find window @9",), + ) + assert result.failed + with pytest.raises(TmuxCommandError): + result.raise_for_status() + + +def test_destructive_safety_metadata() -> None: + """Kill operations are tagged destructive in the registry/catalog.""" + from libtmux.experimental.ops import catalog + + safety = {entry.kind: entry.safety for entry in catalog()} + assert safety["kill_window"] == "destructive" + assert safety["kill_pane"] == "destructive" + assert safety["rename_window"] == "mutating" + + +class SendKeysGuardCase(t.NamedTuple): + """A literal/enter combination and whether SendKeys rejects it.""" + + test_id: str + literal: bool + enter: bool + raises: bool + + +SEND_KEYS_GUARD_CASES = ( + SendKeysGuardCase("plain", literal=False, enter=False, raises=False), + SendKeysGuardCase("enter_only", literal=False, enter=True, raises=False), + SendKeysGuardCase("literal_only", literal=True, enter=False, raises=False), + SendKeysGuardCase("literal_and_enter", literal=True, enter=True, raises=True), +) + + +@pytest.mark.parametrize( + list(SendKeysGuardCase._fields), + SEND_KEYS_GUARD_CASES, + ids=[c.test_id for c in SEND_KEYS_GUARD_CASES], +) +def test_send_keys_literal_enter_guard( + test_id: str, + literal: bool, + enter: bool, + raises: bool, +) -> None: + """literal=True with enter=True is rejected (tmux -l would type 'Enter').""" + if raises: + with pytest.raises(ValueError, match="literal"): + SendKeys(target=PaneId("%1"), keys="x", literal=literal, enter=enter) + else: + op = SendKeys(target=PaneId("%1"), keys="x", literal=literal, enter=enter) + assert op.render()[0] == "send-keys" diff --git a/tests/experimental/ops/test_buffer_ops.py b/tests/experimental/ops/test_buffer_ops.py new file mode 100644 index 000000000..d4b03e93a --- /dev/null +++ b/tests/experimental/ops/test_buffer_ops.py @@ -0,0 +1,174 @@ +"""Tests for the paste-buffer operations (bucket A).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + DeleteBuffer, + LoadBuffer, + PasteBuffer, + SaveBuffer, + SetBuffer, + ShowBuffer, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="set_buffer", + op=SetBuffer(data="hello"), + expected=("set-buffer", "hello"), + ), + RenderCase( + test_id="set_buffer_named", + op=SetBuffer(buffer_name="b0", data="hi"), + expected=("set-buffer", "-b", "b0", "hi"), + ), + RenderCase( + test_id="delete_buffer_named", + op=DeleteBuffer(buffer_name="b0"), + expected=("delete-buffer", "-b", "b0"), + ), + RenderCase( + test_id="delete_buffer_default", + op=DeleteBuffer(), + expected=("delete-buffer",), + ), + RenderCase( + test_id="load_buffer", + op=LoadBuffer(path="/tmp/x"), + expected=("load-buffer", "/tmp/x"), + ), + RenderCase( + test_id="save_buffer", + op=SaveBuffer(path="/tmp/x"), + expected=("save-buffer", "/tmp/x"), + ), + RenderCase( + test_id="save_buffer_append_named", + op=SaveBuffer(buffer_name="b0", path="/tmp/x", append=True), + expected=("save-buffer", "-a", "-b", "b0", "/tmp/x"), + ), + RenderCase( + test_id="paste_buffer", + op=PasteBuffer(target=PaneId("%1")), + expected=("paste-buffer", "-t", "%1"), + ), + RenderCase( + test_id="paste_buffer_delete", + op=PasteBuffer(target=PaneId("%1"), delete=True), + expected=("paste-buffer", "-t", "%1", "-d"), + ), + RenderCase( + test_id="paste_buffer_no_replace", + op=PasteBuffer(target=PaneId("%1"), no_replace=True), + expected=("paste-buffer", "-t", "%1", "-r"), + ), + RenderCase( + test_id="show_buffer", + op=ShowBuffer(buffer_name="b0"), + expected=("show-buffer", "-b", "b0"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_buffer_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each buffer op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_buffer_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0) + assert result_from_dict(result_to_dict(result)) == result + + +def test_show_buffer_joins_lines() -> None: + """show-buffer joins captured lines into the buffer text.""" + result = ShowBuffer().build_result(returncode=0, stdout=("line1", "line2")) + assert result.text == "line1\nline2" + + +def test_set_show_save_delete_buffer_live( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """set-buffer/show-buffer/save-buffer/delete-buffer round-trip a buffer.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + + run(SetBuffer(buffer_name="ops_b", data="hello world"), engine).raise_for_status() + shown = run(ShowBuffer(buffer_name="ops_b"), engine) + assert shown.ok + assert shown.text == "hello world" + + out = tmp_path / "buf.txt" + run(SaveBuffer(buffer_name="ops_b", path=str(out)), engine).raise_for_status() + assert out.read_text() == "hello world" + + assert run(DeleteBuffer(buffer_name="ops_b"), engine).ok + + +def test_load_and_paste_buffer_live( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """load-buffer reads a file into a buffer; paste-buffer targets a pane.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + src = tmp_path / "in.txt" + src.write_text("pasted-content") + run(LoadBuffer(buffer_name="ops_lb", path=str(src)), engine).raise_for_status() + assert run(ShowBuffer(buffer_name="ops_lb"), engine).text == "pasted-content" + + assert run( + PasteBuffer(target=PaneId(pane.pane_id), buffer_name="ops_lb"), + engine, + ).ok diff --git a/tests/experimental/ops/test_catalog.py b/tests/experimental/ops/test_catalog.py new file mode 100644 index 000000000..a62e3ce34 --- /dev/null +++ b/tests/experimental/ops/test_catalog.py @@ -0,0 +1,35 @@ +"""Tests for the registry-driven operation catalog.""" + +from __future__ import annotations + +from libtmux.experimental.ops import catalog, registry + + +def test_catalog_covers_every_registered_operation() -> None: + """The catalog has exactly one entry per registered kind.""" + entries = catalog() + assert [entry.kind for entry in entries] == sorted(registry.kinds()) + + +def test_catalog_entry_mirrors_spec() -> None: + """A catalog entry reflects the operation's registry metadata.""" + entries = {entry.kind: entry for entry in catalog()} + + split = entries["split_window"] + assert split.command == "split-window" + assert split.scope == "window" + assert split.result_type == "SplitWindowResult" + assert split.effects["creates"] == "pane" + assert split.flag_version_gates == {"environment": "3.0"} + assert split.summary + + capture = entries["capture_pane"] + assert capture.safety == "readonly" + assert capture.effects["read_only"] is True + assert capture.flag_version_gates["trim_trailing"] == "3.4" + + +def test_catalog_summary_is_first_docstring_line() -> None: + """Each entry's summary is the operation's one-line description.""" + entries = {entry.kind: entry for entry in catalog()} + assert entries["send_keys"].summary.startswith("Send keys") diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py new file mode 100644 index 000000000..d8babf1af --- /dev/null +++ b/tests/experimental/ops/test_chain.py @@ -0,0 +1,265 @@ +"""Tests for lazy-plan chainability (>> composition and ; folding).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import CommandResult +from libtmux.experimental.ops import ( + CapturePane, + FoldingPlanner, + KillWindow, + LazyPlan, + OpChain, + RenameWindow, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._chain import ( + attribute_marked, + ensure_chainable, + render_chain, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import OperationError + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class _CountingEngine: + """An engine that counts dispatches and returns a canned result.""" + + def __init__(self, *, returncode: int = 0, stderr: tuple[str, ...] = ()) -> None: + self.returncode = returncode + self.stderr = stderr + self.calls: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> CommandResult: + """Record the argv and return the canned result.""" + self.calls.append(request.args) + return CommandResult( + cmd=("tmux", *request.args), + stderr=self.stderr, + returncode=self.returncode, + ) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +def test_rshift_builds_opchain() -> None: + """``>>`` composes operations into an ordered OpChain.""" + chain = SendKeys(target=PaneId("%1"), keys="q") >> RenameWindow( + target=WindowId("@1"), + name="done", + ) + assert isinstance(chain, OpChain) + assert [op.kind for op in chain] == ["send_keys", "rename_window"] + + +def test_ensure_chainable_rejects_output_ops() -> None: + """Output/creation ops are not chainable (fail closed).""" + ensure_chainable(SendKeys(target=PaneId("%1"), keys="q")) # ok + with pytest.raises(OperationError, match="not chainable"): + ensure_chainable(CapturePane(target=PaneId("%1"))) + with pytest.raises(OperationError, match="not chainable"): + ensure_chainable(SplitWindow(target=WindowId("@1"))) + + +def test_render_chain_joins_with_separator() -> None: + """Chainable ops render to one argv with standalone ';' separators.""" + argv = render_chain( + [ + SendKeys(target=PaneId("%1"), keys="vim", enter=True), + RenameWindow(target=WindowId("@1"), name="edit"), + ], + ) + assert argv == ( + "send-keys", + "-t", + "%1", + "vim", + "Enter", + ";", + "rename-window", + "-t", + "@1", + "edit", + ) + + +def test_fold_dispatches_once() -> None: + """A run of chainable ops folds into a single engine dispatch.""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(RenameWindow(target=WindowId("@1"), name="x")) + plan.add(KillWindow(target=WindowId("@2"))) + engine = _CountingEngine() + + outcome = plan.execute(engine, planner=FoldingPlanner()) + + assert len(engine.calls) == 1 # all three folded into one ';' dispatch + assert ";" in engine.calls[0] + assert outcome.ok + assert [r.status for r in outcome.results] == ["complete", "complete", "complete"] + + +def test_no_fold_dispatches_per_op() -> None: + """Without folding, each op dispatches on its own (default behaviour).""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(RenameWindow(target=WindowId("@1"), name="x")) + engine = _CountingEngine() + + plan.execute(engine) # fold defaults to False + + assert len(engine.calls) == 2 + + +def test_fold_failure_attributes_first_failed_rest_skipped() -> None: + """A folded failure marks the first op failed and the rest skipped.""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(RenameWindow(target=WindowId("@1"), name="x")) + plan.add(KillWindow(target=WindowId("@2"))) + engine = _CountingEngine(returncode=1, stderr=("boom",)) + + outcome = plan.execute(engine, planner=FoldingPlanner()) + + assert [r.status for r in outcome.results] == ["failed", "skipped", "skipped"] + assert not outcome.ok + + +def test_fold_keeps_creation_ops_unfolded() -> None: + """A non-chainable creator dispatches alone; chainable neighbours fold.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) # not chainable + plan.add(SendKeys(target=pane, keys="vim")) # chainable, targets new pane + plan.add(RenameWindow(target=WindowId("@1"), name="x")) # chainable + from libtmux.experimental.engines import ConcreteEngine + + outcome = plan.execute(ConcreteEngine(), planner=FoldingPlanner()) + + # split resolved the pane id; the send-keys folded with rename, retargeted + assert outcome.results[1].argv[:3] == ("send-keys", "-t", "%1") + assert outcome.ok + + +class MarkedAttrCase(t.NamedTuple): + """A merged {marked} dispatch result and the per-op statuses it yields.""" + + test_id: str + merged: CommandResult + new_id: str | None + create_status: str + decorate_statuses: list[str] + + +_MARK_CREATE = SplitWindow(target=WindowId("@1")) +_MARK_DECORATES = ( + SendKeys(target=PaneId("%9"), keys="a"), + SendKeys(target=PaneId("%9"), keys="b"), +) + +MARKED_ATTR_CASES = ( + MarkedAttrCase( + test_id="all_succeed", + merged=CommandResult(cmd=("tmux",), stdout=("%2",), returncode=0), + new_id="%2", + create_status="complete", + decorate_statuses=["complete", "complete"], + ), + MarkedAttrCase( + test_id="create_fails", + merged=CommandResult(cmd=("tmux",), returncode=1, stderr=("boom",)), + new_id=None, + create_status="failed", + decorate_statuses=["skipped", "skipped"], + ), + MarkedAttrCase( + test_id="capture_false_success", + merged=CommandResult(cmd=("tmux",), returncode=0), + new_id=None, + create_status="complete", + decorate_statuses=["complete", "complete"], + ), + MarkedAttrCase( + test_id="decorate_fails", + merged=CommandResult( + cmd=("tmux",), stdout=("%2",), returncode=1, stderr=("x",) + ), + new_id="%2", + create_status="complete", + decorate_statuses=["failed", "skipped"], + ), +) + + +@pytest.mark.parametrize( + list(MarkedAttrCase._fields), + MARKED_ATTR_CASES, + ids=[c.test_id for c in MARKED_ATTR_CASES], +) +def test_attribute_marked( + test_id: str, + merged: CommandResult, + new_id: str | None, + create_status: str, + decorate_statuses: list[str], +) -> None: + """A failed create skips all decorates; a failed decorate blames the first.""" + created, decorated, got_id = attribute_marked(_MARK_CREATE, _MARK_DECORATES, merged) + assert got_id == new_id + assert created.status == create_status + assert [r.status for r in decorated] == decorate_statuses + + +def test_attribute_marked_decorate_target_is_concrete_pane() -> None: + """Decorate results address the concrete new pane, not {marked} (for replay).""" + merged = CommandResult(cmd=("tmux",), stdout=("%2",), returncode=0) + _created, decorated, _new_id = attribute_marked( + _MARK_CREATE, + _MARK_DECORATES, + merged, + ) + assert all(r.operation.target == PaneId("%2") for r in decorated) + + +def test_attribute_marked_failed_decorate_drops_create_stdout() -> None: + """A failed decorate is not credited with the create's captured pane id.""" + merged = CommandResult( + cmd=("tmux",), stdout=("%2",), returncode=1, stderr=("boom",) + ) + _created, decorated, _new_id = attribute_marked( + _MARK_CREATE, + _MARK_DECORATES, + merged, + ) + assert decorated[0].status == "failed" + assert "%2" not in decorated[0].stdout + + +def test_attribute_marked_blank_stdout_is_no_id() -> None: + """A whitespace-only captured id is treated as no id (never bound as '').""" + merged = CommandResult(cmd=("tmux",), stdout=(" ",), returncode=0) + _created, _decorated, new_id = attribute_marked( + _MARK_CREATE, + _MARK_DECORATES, + merged, + ) + assert new_id is None + + +def test_add_chain() -> None: + """A composed OpChain can be added to a plan in order.""" + plan = LazyPlan() + plan.add_chain( + SendKeys(target=PaneId("%1"), keys="q") >> KillWindow(target=WindowId("@1")), + ) + assert [op.kind for op in plan] == ["send_keys", "kill_window"] diff --git a/tests/experimental/ops/test_control_parser.py b/tests/experimental/ops/test_control_parser.py new file mode 100644 index 000000000..7517e330c --- /dev/null +++ b/tests/experimental/ops/test_control_parser.py @@ -0,0 +1,66 @@ +"""Pure (no-tmux) tests for the control-mode block parser.""" + +from __future__ import annotations + +from libtmux.experimental.engines import ControlModeParser + + +def test_parses_success_block() -> None: + """A ``%begin``/``%end`` pair yields one non-error block with its body.""" + parser = ControlModeParser() + parser.feed(b"%begin 1 1 1\nhello\nworld\n%end 1 1 1\n") + blocks = parser.blocks() + assert len(blocks) == 1 + assert not blocks[0].is_error + assert blocks[0].body == (b"hello", b"world") + + +def test_parses_error_block() -> None: + """A ``%error`` close marks the block as an error.""" + parser = ControlModeParser() + parser.feed(b"%begin 2 5 1\ncan't find pane\n%error 2 5 1\n") + block = parser.blocks()[0] + assert block.is_error + assert block.body == (b"can't find pane",) + + +def test_handles_split_chunks() -> None: + """Bytes split mid-line across feeds still parse into one block.""" + parser = ControlModeParser() + parser.feed(b"%begin 1 1 1\nhel") + parser.feed(b"lo\n%end 1 1 1\n") + assert parser.blocks()[0].body == (b"hello",) + + +def test_blocks_drains() -> None: + """``blocks`` returns parsed blocks once, then is empty.""" + parser = ControlModeParser() + parser.feed(b"%begin 1 1 1\nx\n%end 1 1 1\n") + assert len(parser.blocks()) == 1 + assert parser.blocks() == [] + + +def test_ignores_noise_outside_blocks() -> None: + """Notification lines outside a block are not command blocks.""" + parser = ControlModeParser() + parser.feed(b"%output %1 hi\n%begin 1 1 1\nok\n%end 1 1 1\n") + blocks = parser.blocks() + assert len(blocks) == 1 + assert blocks[0].body == (b"ok",) + + +def test_surfaces_notifications() -> None: + """Bare ``%`` lines outside blocks are surfaced as notifications.""" + parser = ControlModeParser() + parser.feed(b"%window-add @3\n%begin 1 1 1\nok\n%end 1 1 1\n%output %1 hi\n") + assert parser.notifications() == [b"%window-add @3", b"%output %1 hi"] + # block lines are not double-counted as notifications + assert parser.blocks()[0].body == (b"ok",) + + +def test_notifications_drain() -> None: + """``notifications`` returns each notification once, then is empty.""" + parser = ControlModeParser() + parser.feed(b"%window-close @3\n") + assert parser.notifications() == [b"%window-close @3"] + assert parser.notifications() == [] diff --git a/tests/experimental/ops/test_execute.py b/tests/experimental/ops/test_execute.py new file mode 100644 index 000000000..017ad6ba1 --- /dev/null +++ b/tests/experimental/ops/test_execute.py @@ -0,0 +1,107 @@ +"""Tests for the :func:`run` / :func:`arun` execution bridge. + +These use in-memory fake engines so they need no tmux server -- the same +property that lets the contract suite run an operation through every engine. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.ops import SendKeys, SplitWindow, arun, run +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import TmuxCommandError + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class FakeEngine: + """A synchronous fake engine that echoes argv and a canned stdout.""" + + def __init__(self, stdout: tuple[str, ...] = (), returncode: int = 0) -> None: + self.stdout = stdout + self.returncode = returncode + self.calls: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> t.Any: + """Record the request and return a canned result.""" + from libtmux.experimental.engines.base import CommandResult + + self.calls.append(request.args) + return CommandResult( + cmd=("tmux", *request.args), + stdout=self.stdout, + stderr=() if self.returncode == 0 else ("boom",), + returncode=self.returncode, + ) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[t.Any]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +class AsyncFakeEngine: + """An asynchronous fake engine mirroring :class:`FakeEngine`.""" + + def __init__(self, stdout: tuple[str, ...] = (), returncode: int = 0) -> None: + self.stdout = stdout + self.returncode = returncode + + async def run(self, request: CommandRequest) -> t.Any: + """Return a canned result asynchronously.""" + from libtmux.experimental.engines.base import CommandResult + + return CommandResult( + cmd=("tmux", *request.args), + stdout=self.stdout, + returncode=self.returncode, + ) + + async def run_batch(self, requests: Sequence[CommandRequest]) -> list[t.Any]: + """Execute each request in order.""" + return [await self.run(req) for req in requests] + + +def test_run_returns_typed_result() -> None: + """``run`` renders, dispatches, and returns the operation's typed result.""" + engine = FakeEngine(stdout=("%9",)) + result = run(SplitWindow(target=WindowId("@1")), engine) + assert result.new_pane_id == "%9" + assert result.argv == ("split-window", "-t", "@1", "-v", "-P", "-F", "#{pane_id}") + assert engine.calls == [result.argv] + + +def test_run_does_not_raise_on_failure() -> None: + """A tmux failure is data on the result; ``run`` itself never raises.""" + engine = FakeEngine(returncode=1) + result = run(SendKeys(target=PaneId("%9"), keys="x"), engine) + assert result.failed + with pytest.raises(TmuxCommandError): + result.raise_for_status() + + +def test_run_version_threads_through() -> None: + """The ``version`` argument reaches operation rendering.""" + from libtmux.experimental.ops import CapturePane + + engine = FakeEngine() + result = run( + CapturePane(target=PaneId("%1"), trim_trailing=True), + engine, + version="3.3", + ) + assert "-T" not in result.argv + + +def test_arun_shares_render_and_build() -> None: + """``arun`` produces the same typed result as ``run`` via the async path.""" + engine = AsyncFakeEngine(stdout=("%5",)) + result = asyncio.run(arun(SplitWindow(target=WindowId("@1")), engine)) + assert result.new_pane_id == "%5" + assert result.ok diff --git a/tests/experimental/ops/test_lifecycle_ops.py b/tests/experimental/ops/test_lifecycle_ops.py new file mode 100644 index 000000000..f8a8d4083 --- /dev/null +++ b/tests/experimental/ops/test_lifecycle_ops.py @@ -0,0 +1,200 @@ +"""Tests for server/session lifecycle, option, and environment operations.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + KillServer, + RunShell, + SetEnvironment, + SetHook, + SetOption, + SetWindowOption, + ShowOptions, + SourceFile, + StartServer, + SuspendClient, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import ClientName, SessionId, WindowId + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="start_server", + op=StartServer(), + expected=("start-server",), + ), + RenderCase( + test_id="kill_server", + op=KillServer(), + expected=("kill-server",), + ), + RenderCase( + test_id="run_shell", + op=RunShell(command_line="echo hi"), + expected=("run-shell", "echo hi"), + ), + RenderCase( + test_id="run_shell_background", + op=RunShell(command_line="x", background=True), + expected=("run-shell", "-b", "x"), + ), + RenderCase( + test_id="source_file", + op=SourceFile(path="~/.tmux.conf"), + expected=("source-file", "~/.tmux.conf"), + ), + RenderCase( + test_id="suspend_client", + op=SuspendClient(target=ClientName("/dev/pts/1")), + expected=("suspend-client", "-t", "/dev/pts/1"), + ), + RenderCase( + test_id="set_option", + op=SetOption(option="status", value="on"), + expected=("set-option", "status", "on"), + ), + RenderCase( + test_id="set_option_global", + op=SetOption(global_=True, option="status", value="on"), + expected=("set-option", "-g", "status", "on"), + ), + RenderCase( + test_id="set_option_unset", + op=SetOption(option="status", unset=True), + expected=("set-option", "-u", "status"), + ), + RenderCase( + test_id="set_window_option", + op=SetWindowOption(option="mode-keys", value="vi"), + expected=("set-window-option", "mode-keys", "vi"), + ), + RenderCase( + test_id="set_environment", + op=SetEnvironment(name="FOO", value="bar"), + expected=("set-environment", "FOO", "bar"), + ), + RenderCase( + test_id="set_environment_unset", + op=SetEnvironment(global_=True, name="FOO", unset=True), + expected=("set-environment", "-g", "-u", "FOO"), + ), + RenderCase( + test_id="set_hook", + op=SetHook(name="after-new-window", hook_command="display hi"), + expected=("set-hook", "after-new-window", "display hi"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_lifecycle_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_lifecycle_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0) + assert result_from_dict(result_to_dict(result)) == result + + +def test_set_and_show_option_live(session: Session) -> None: + """set-option writes a session option that show-options reads back.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + run( + SetOption(target=SessionId(sid), option="@ops_var", value="hello"), + engine, + ).raise_for_status() + shown = run(ShowOptions(target=SessionId(sid)), engine) + assert shown.ok + assert shown.options.get("@ops_var") == "hello" + + +def test_set_window_option_and_environment_live(session: Session) -> None: + """set-window-option and set-environment succeed against real objects.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + window = session.active_window + assert sid is not None and window.window_id is not None + + assert run( + SetWindowOption(target=WindowId(window.window_id), option="@w", value="x"), + engine, + ).ok + assert run( + SetEnvironment(target=SessionId(sid), name="OPS_ENV", value="1"), + engine, + ).ok + assert run( + SetHook( + target=SessionId(sid), + name="after-new-window", + hook_command="display-message ok", + ), + engine, + ).ok + + +def test_run_shell_source_file_start_server_live( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """run-shell, source-file, and start-server all succeed.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + + assert run(RunShell(command_line="true"), engine).ok + assert run(StartServer(), engine).ok + + conf = tmp_path / "snippet.conf" + conf.write_text("set-option -g @sourced yes\n") + assert run(SourceFile(path=str(conf)), engine).ok diff --git a/tests/experimental/ops/test_operation.py b/tests/experimental/ops/test_operation.py new file mode 100644 index 000000000..39b14529e --- /dev/null +++ b/tests/experimental/ops/test_operation.py @@ -0,0 +1,144 @@ +"""Tests for the base :class:`~libtmux.experimental.ops.operation.Operation`.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +import pytest + +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._types import Effects, PaneId, WindowId +from libtmux.experimental.ops.exc import VersionUnsupported +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True, kw_only=True) +class _FutureOp(Operation[Result]): + """A synthetic operation gated to a future tmux version, for tests.""" + + kind = "_future_op_test" + command = "future-cmd" + scope = "server" + result_cls = Result + effects = Effects() + min_version = "99.0" + flag_version_map: t.ClassVar[dict[str, str]] = {"feat": "99.0"} + + +def test_render_includes_target_then_args() -> None: + """``render`` emits ``command -t target *args`` in order.""" + op = SendKeys(target=PaneId("%1"), keys="echo hi", enter=True) + assert op.render() == ("send-keys", "-t", "%1", "echo hi", "Enter") + + +def test_render_without_target() -> None: + """An operation with no target omits ``-t``.""" + op = SelectLayout(layout="tiled") + assert op.render() == ("select-layout", "tiled") + + +def test_version_gate_drops_unsupported_flag() -> None: + """A version-gated flag is dropped on an older tmux and kept on a newer one.""" + op = CapturePane(target=PaneId("%1"), trim_trailing=True) + assert op.render(version="3.3") == ("capture-pane", "-t", "%1", "-p") + assert op.render(version="3.4") == ("capture-pane", "-t", "%1", "-p", "-T") + assert op.render() == ("capture-pane", "-t", "%1", "-p", "-T") + + +def test_check_version_raises_when_too_low() -> None: + """An operation older tmux cannot satisfy raises on render.""" + op = _FutureOp() + with pytest.raises(VersionUnsupported, match="requires tmux >= 99"): + op.render(version="3.4") + + +def test_check_version_passes_when_satisfied() -> None: + """No version (or a satisfying one) renders without error.""" + op = _FutureOp() + assert op.render() == ("future-cmd",) + assert op.render(version="99.0") == ("future-cmd",) + + +class VersionCase(t.NamedTuple): + """A tmux version string and whether the 99.0-gated op accepts it.""" + + test_id: str + version: str | None + satisfied: bool + + +VERSION_CASES = ( + VersionCase("master_suffix", "3.7-master", True), + VersionCase("bare_master", "master", True), + VersionCase("none", None, True), + VersionCase("exact", "99.0", True), + VersionCase("too_old", "3.4", False), +) + + +@pytest.mark.parametrize( + list(VersionCase._fields), + VERSION_CASES, + ids=[c.test_id for c in VERSION_CASES], +) +def test_version_gates_normalize_master( + test_id: str, + version: str | None, + satisfied: bool, +) -> None: + """A "master"/suffixed version sorts above tagged releases for both gates.""" + op = _FutureOp() + if satisfied: + op.check_version(version) # no raise + assert op.flag_available("feat", version) is True + else: + with pytest.raises(VersionUnsupported): + op.check_version(version) + assert op.flag_available("feat", version) is False + + +def test_build_result_parses_payload() -> None: + """``split-window`` parses the captured new-pane id into its result.""" + op = SplitWindow(target=WindowId("@1")) + result = op.build_result(returncode=0, stdout=("%7",)) + assert result.new_pane_id == "%7" + assert result.ok + assert result.operation is op + + +def test_build_result_failure_status() -> None: + """A nonzero return code yields a ``failed`` result and no payload.""" + op = SplitWindow(target=WindowId("@1")) + result = op.build_result(returncode=1, stderr=("no space for new pane",)) + assert result.status == "failed" + assert result.new_pane_id is None + + +def test_operations_are_frozen() -> None: + """Operations are immutable values.""" + op = SendKeys(target=PaneId("%1"), keys="x") + with pytest.raises((AttributeError, TypeError)): + op.keys = "y" # type: ignore[misc] + + +@pytest.mark.parametrize( + "op", + [ + pytest.param(SplitWindow(target=WindowId("@1")), id="split_window"), + pytest.param(CapturePane(target=PaneId("%1")), id="capture_pane"), + pytest.param(SendKeys(target=PaneId("%1"), keys="x"), id="send_keys"), + pytest.param(SelectLayout(target=WindowId("@1")), id="select_layout"), + ], +) +def test_render_is_nonempty_argv(op: Operation[t.Any]) -> None: + """Every seed operation renders to a non-empty argv starting with command.""" + argv = op.render() + assert argv + assert argv[0] == op.command diff --git a/tests/experimental/ops/test_pane_ops.py b/tests/experimental/ops/test_pane_ops.py new file mode 100644 index 000000000..eecad6c72 --- /dev/null +++ b/tests/experimental/ops/test_pane_ops.py @@ -0,0 +1,207 @@ +"""Tests for the pane mutation/creation operations (bucket A).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + BreakPane, + ClearHistory, + JoinPane, + LastPane, + MovePane, + PipePane, + ResizePane, + RespawnPane, + SelectPane, + SplitWindow, + SwapPane, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="select_pane", + op=SelectPane(target=PaneId("%1")), + expected=("select-pane", "-t", "%1"), + ), + RenderCase( + test_id="select_pane_direction_zoom", + op=SelectPane(target=PaneId("%2"), direction="L", zoom=True), + expected=("select-pane", "-t", "%2", "-L", "-Z"), + ), + RenderCase( + test_id="last_pane", + op=LastPane(target=WindowId("@1")), + expected=("last-pane", "-t", "@1"), + ), + RenderCase( + test_id="resize_pane_height", + op=ResizePane(target=PaneId("%1"), height=20), + expected=("resize-pane", "-t", "%1", "-y20"), + ), + RenderCase( + test_id="resize_pane_direction", + op=ResizePane(target=PaneId("%1"), direction="D", adjustment=5), + expected=("resize-pane", "-t", "%1", "-D", "5"), + ), + RenderCase( + test_id="respawn_pane_kill", + op=RespawnPane(target=PaneId("%1"), kill=True), + expected=("respawn-pane", "-t", "%1", "-k"), + ), + RenderCase( + test_id="pipe_pane", + op=PipePane(target=PaneId("%1"), command_line="cat"), + expected=("pipe-pane", "-t", "%1", "cat"), + ), + RenderCase( + test_id="clear_history", + op=ClearHistory(target=PaneId("%1")), + expected=("clear-history", "-t", "%1"), + ), + RenderCase( + test_id="swap_pane", + op=SwapPane(target=PaneId("%1"), src_target=PaneId("%2")), + expected=("swap-pane", "-t", "%1", "-s", "%2"), + ), + RenderCase( + test_id="join_pane", + op=JoinPane(target=WindowId("@1"), src_target=PaneId("%2")), + expected=("join-pane", "-t", "@1", "-v", "-d", "-s", "%2"), + ), + RenderCase( + test_id="move_pane", + op=MovePane(target=WindowId("@1"), src_target=PaneId("%2")), + expected=("move-pane", "-t", "@1", "-v", "-d", "-s", "%2"), + ), + RenderCase( + test_id="break_pane", + op=BreakPane(src_target=PaneId("%2"), name="logs"), + expected=( + "break-pane", + "-d", + "-n", + "logs", + "-P", + "-F", + "#{window_id}", + "-s", + "%2", + ), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_pane_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each pane op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_pane_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op (incl. its src_target) and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0, stdout=("@7",)) + assert result_from_dict(result_to_dict(result)) == result + + +def test_break_pane_captures_new_window_id() -> None: + """break-pane parses the captured window id into the typed result.""" + result = BreakPane(src_target=PaneId("%2")).build_result( + returncode=0, + stdout=("@9",), + ) + assert result.new_id == "@9" + assert result.created_id == "@9" + + +def test_select_pane_live(session: Session) -> None: + """select-pane makes the requested pane active.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + original = session.active_pane + assert original is not None and original.pane_id is not None + + run(SplitWindow(target=WindowId(window.window_id)), engine).raise_for_status() + run(SelectPane(target=PaneId(original.pane_id)), engine).raise_for_status() + + window.refresh() + active = window.active_pane + assert active is not None + assert active.pane_id == original.pane_id + + +def test_resize_and_clear_live(session: Session) -> None: + """resize-pane and clear-history succeed against a real pane.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + assert run(ResizePane(target=PaneId(pane.pane_id), height=10), engine).ok + assert run(ClearHistory(target=PaneId(pane.pane_id)), engine).ok + + +def test_break_and_swap_live(session: Session) -> None: + """break-pane creates a window; swap-pane swaps two real panes.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + + split = run(SplitWindow(target=WindowId(window.window_id)), engine) + new_pane = split.new_pane_id + assert new_pane is not None + + window.refresh() + first = window.panes[0].pane_id + assert first is not None + assert run(SwapPane(target=PaneId(first), src_target=PaneId(new_pane)), engine).ok + + broken = run(BreakPane(src_target=PaneId(new_pane)), engine) + assert broken.ok + assert broken.new_id is not None + assert session.server.windows.get(window_id=broken.new_id) is not None diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py new file mode 100644 index 000000000..812d20665 --- /dev/null +++ b/tests/experimental/ops/test_plan.py @@ -0,0 +1,142 @@ +"""Tests for the lazy plan and deferred-ref resolution.""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.ops import ( + BreakPane, + JoinPane, + LazyPlan, + MarkedPlanner, + MovePane, + SendKeys, + SplitWindow, + SwapPane, +) +from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId +from libtmux.experimental.ops.exc import OperationError + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + + +def test_plan_records_without_executing() -> None: + """Building a plan touches no engine; it just records operations.""" + plan = LazyPlan() + plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=PaneId("%1"), keys="x")) + assert len(plan) == 2 + assert [op.kind for op in plan] == ["split_window", "send_keys"] + + +def test_plan_resolves_forward_ref() -> None: + """A later step can target the pane an earlier split creates.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + + outcome = plan.execute(ConcreteEngine()) + + assert outcome.bindings == {0: "%1"} + assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") + assert outcome.ok + + +class SrcResolveCase(t.NamedTuple): + """A dual-target op whose ``src_target`` is a forward :class:`SlotRef`.""" + + test_id: str + op: Operation[t.Any] + + +SRC_RESOLVE_CASES = ( + SrcResolveCase("swap_pane", SwapPane(target=PaneId("%0"), src_target=SlotRef(0))), + SrcResolveCase("join_pane", JoinPane(target=WindowId("@0"), src_target=SlotRef(0))), + SrcResolveCase("move_pane", MovePane(target=WindowId("@0"), src_target=SlotRef(0))), + SrcResolveCase("break_pane", BreakPane(src_target=SlotRef(0))), +) + + +@pytest.mark.parametrize( + list(SrcResolveCase._fields), + SRC_RESOLVE_CASES, + ids=[c.test_id for c in SRC_RESOLVE_CASES], +) +def test_plan_resolves_src_target(test_id: str, op: Operation[t.Any]) -> None: + """A SlotRef used as ``src_target`` resolves to the captured id.""" + plan = LazyPlan() + plan.add(SplitWindow(target=WindowId("@1"))) # slot 0 -> %1 + plan.add(op) + outcome = plan.execute(ConcreteEngine()) + assert outcome.ok + assert outcome.results[1].argv[-2:] == ("-s", "%1") + + +class MarkedSrcCase(t.NamedTuple): + """A {marked} decorate whose ``src_target`` references an earlier bound slot.""" + + test_id: str + op: Operation[t.Any] + + +MARKED_SRC_CASES = ( + MarkedSrcCase("swap_pane", SwapPane(target=SlotRef(1), src_target=SlotRef(0))), + MarkedSrcCase("join_pane", JoinPane(target=SlotRef(1), src_target=SlotRef(0))), + MarkedSrcCase("move_pane", MovePane(target=SlotRef(1), src_target=SlotRef(0))), +) + + +@pytest.mark.parametrize( + list(MarkedSrcCase._fields), + MARKED_SRC_CASES, + ids=[c.test_id for c in MARKED_SRC_CASES], +) +def test_marked_plan_resolves_decorate_src_target( + test_id: str, + op: Operation[t.Any], +) -> None: + """A {marked} decorate's ``src_target`` SlotRef resolves to the bound id.""" + plan = LazyPlan() + plan.add(SplitWindow(target=WindowId("@1"))) # slot 0 -> %1 (own dispatch) + plan.add(SplitWindow(target=WindowId("@1"))) # slot 1 -> the marked-fold creator + plan.add(op) # slot 2 -> decorate: target {marked}, src_target -> slot 0 + outcome = plan.execute(ConcreteEngine(), planner=MarkedPlanner()) + assert outcome.ok + assert outcome.results[2].argv[-2:] == ("-s", "%1") + + +def test_plan_aexecute_matches_execute() -> None: + """The async driver resolves refs identically to the sync driver.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + + outcome = asyncio.run(plan.aexecute(AsyncConcreteEngine())) + + assert outcome.bindings == {0: "%1"} + assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") + + +def test_plan_serialization_round_trip() -> None: + """A plan (including its SlotRef targets) survives a list round-trip.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="x")) + + revived = LazyPlan.from_list(plan.to_list()) + + assert revived.operations == plan.operations + + +def test_plan_unresolvable_ref_fails_closed() -> None: + """Targeting a step that creates nothing raises a clear error.""" + plan = LazyPlan() + typed = plan.add(SendKeys(target=PaneId("%1"), keys="x")) # creates no id + plan.add(SendKeys(target=typed, keys="y")) + with pytest.raises(OperationError, match="no captured id"): + plan.execute(ConcreteEngine()) diff --git a/tests/experimental/ops/test_planner.py b/tests/experimental/ops/test_planner.py new file mode 100644 index 000000000..8595982a2 --- /dev/null +++ b/tests/experimental/ops/test_planner.py @@ -0,0 +1,146 @@ +"""Tests for pluggable planners and the {marked} fold. + +Planners must produce the same PlanResult while differing only in dispatch +count -- the property that makes them A/B-testable. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + MarkedPlanner, + SendKeys, + SequentialPlanner, + SplitWindow, +) +from libtmux.experimental.ops._types import PaneId, WindowId + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + from libtmux.experimental.ops.planner import Planner + from libtmux.session import Session + + +class _CountingEngine: + """Engine that counts dispatches and echoes a fabricated pane id.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, ...]] = [] + self._pane = 0 + + def run(self, request: CommandRequest) -> CommandResult: + """Record argv; fabricate a pane id when an id is captured.""" + from libtmux.experimental.engines.base import CommandResult + + self.calls.append(request.args) + stdout: tuple[str, ...] = () + if "-F" in request.args and "#{pane_id}" in request.args: + self._pane += 1 + stdout = (f"%{self._pane}",) + return CommandResult(cmd=("tmux", *request.args), stdout=stdout, returncode=0) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +def _build_plan() -> LazyPlan: + """Split a window, then decorate the new pane (the {marked}-foldable shape).""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + plan.add(SendKeys(target=pane, keys=":w", enter=True)) + return plan + + +class PlannerCase(t.NamedTuple): + """One planner and the dispatch count it should produce for the plan.""" + + test_id: str + planner: Planner + dispatches: int + + +PLANNER_CASES = ( + PlannerCase(test_id="sequential", planner=SequentialPlanner(), dispatches=3), + PlannerCase(test_id="folding", planner=FoldingPlanner(), dispatches=2), + PlannerCase(test_id="marked", planner=MarkedPlanner(), dispatches=1), +) + + +@pytest.mark.parametrize( + list(PlannerCase._fields), + PLANNER_CASES, + ids=[c.test_id for c in PLANNER_CASES], +) +def test_planner_dispatch_count( + test_id: str, + planner: Planner, + dispatches: int, +) -> None: + """Each planner produces the expected number of tmux dispatches.""" + engine = _CountingEngine() + _build_plan().execute(engine, planner=planner) + assert len(engine.calls) == dispatches + + +def test_planners_agree_on_result() -> None: + """Different planners yield the same per-op result (status + new pane id).""" + + def outcome(planner: Planner) -> tuple[list[str], str | None]: + result = _build_plan().execute(_CountingEngine(), planner=planner) + first = result.results[0] + return [r.status for r in result.results], first.created_id + + sequential = outcome(SequentialPlanner()) + assert outcome(FoldingPlanner()) == sequential + assert outcome(MarkedPlanner()) == sequential + assert sequential == (["complete", "complete", "complete"], "%1") + + +def test_marked_renders_single_dispatch() -> None: + """The {marked} fold issues split + mark + decorates + unmark in one call.""" + engine = _CountingEngine() + _build_plan().execute(engine, planner=MarkedPlanner()) + (argv,) = engine.calls + assert "#{pane_id}" in argv # split captures the new pane id + assert "-m" in argv and "-M" in argv # mark set then cleared + assert "{marked}" in argv # decorates target the marked register + + +def test_marked_falls_back_without_pattern() -> None: + """A non-creator chainable run still folds (no {marked} shape required).""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(SendKeys(target=PaneId("%1"), keys="b")) + engine = _CountingEngine() + plan.execute(engine, planner=MarkedPlanner()) + assert len(engine.calls) == 1 # folded as a plain ; chain + + +def test_marked_fold_live(session: Session) -> None: + """The {marked} fold creates and decorates a real pane in one dispatch.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + window = session.active_window + assert window.window_id is not None + engine = SubprocessEngine.for_server(server) + + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId(window.window_id))) + plan.add(SendKeys(target=pane, keys="echo marked", enter=True)) + + outcome = plan.execute(engine, planner=MarkedPlanner()) + + assert outcome.ok + new_id = outcome.results[0].created_id + assert new_id is not None + assert server.panes.get(pane_id=new_id) is not None diff --git a/tests/experimental/ops/test_read_breadth.py b/tests/experimental/ops/test_read_breadth.py new file mode 100644 index 000000000..463171764 --- /dev/null +++ b/tests/experimental/ops/test_read_breadth.py @@ -0,0 +1,238 @@ +"""Tests for the non-list read ops (has-session/display-message/show-options). + +These cover the read seam beyond the ``list-*`` family: a typed existence +query, a format evaluation, an option dump, and the client listing. Each op +renders an inert argv and parses tmux output into a typed result without a live +server; live tests then exercise them against real tmux. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + DisplayMessage, + HasSession, + ListClients, + ShowOptions, + result_from_dict, + result_to_dict, +) +from libtmux.experimental.ops._types import NameRef, PaneId, SessionId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the argv fragments its render must contain.""" + + test_id: str + op: Operation[t.Any] + fragments: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="has_session", + op=HasSession(target=SessionId("$0")), + fragments=("has-session", "-t", "$0"), + ), + RenderCase( + test_id="display_message", + op=DisplayMessage(target=PaneId("%1"), message="#{pane_id}"), + fragments=("display-message", "-t", "%1", "-p", "#{pane_id}"), + ), + RenderCase( + test_id="show_options_global", + op=ShowOptions(global_=True), + fragments=("show-options", "-g"), + ), + RenderCase( + test_id="show_options_server_inherited", + op=ShowOptions(server=True, include_inherited=True), + fragments=("show-options", "-s", "-A"), + ), + RenderCase( + test_id="list_clients", + op=ListClients(), + fragments=("list-clients", "-F"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_read_op_render( + test_id: str, + op: Operation[t.Any], + fragments: tuple[str, ...], +) -> None: + """Each read op renders the expected argv fragments.""" + argv = op.render(version="3.2a") + for fragment in fragments: + assert fragment in argv + + +class ParseCase(t.NamedTuple): + """An op plus a synthesized tmux outcome and the result fields it yields.""" + + test_id: str + op: Operation[t.Any] + returncode: int + stdout: tuple[str, ...] + expected: dict[str, t.Any] + + +PARSE_CASES = ( + ParseCase( + test_id="has_session_exists", + op=HasSession(target=SessionId("$0")), + returncode=0, + stdout=(), + expected={"exists": True, "status": "complete"}, + ), + ParseCase( + test_id="has_session_missing", + op=HasSession(target=SessionId("$9")), + returncode=1, + stdout=(), + expected={"exists": False, "status": "complete"}, + ), + ParseCase( + test_id="display_message_text", + op=DisplayMessage(message="#{pane_id}"), + returncode=0, + stdout=("%1",), + expected={"text": "%1"}, + ), + ParseCase( + test_id="display_message_multiline", + op=DisplayMessage(message="#{pane_id}"), + returncode=0, + stdout=("line1", "line2"), + expected={"text": "line1\nline2"}, + ), + ParseCase( + test_id="display_message_empty", + op=DisplayMessage(message="#{pane_id}"), + returncode=0, + stdout=(), + expected={"text": ""}, + ), + ParseCase( + test_id="show_options_pairs", + op=ShowOptions(), + returncode=0, + stdout=("status on", "history-limit 2000"), + expected={"options": {"status": "on", "history-limit": "2000"}}, + ), +) + + +@pytest.mark.parametrize( + list(ParseCase._fields), + PARSE_CASES, + ids=[c.test_id for c in PARSE_CASES], +) +def test_read_op_parse( + test_id: str, + op: Operation[t.Any], + returncode: int, + stdout: tuple[str, ...], + expected: dict[str, t.Any], +) -> None: + """Each read op parses its tmux output into the expected result fields.""" + result = op.build_result(returncode=returncode, stdout=stdout) + for attr, value in expected.items(): + assert getattr(result, attr) == value + + +@pytest.mark.parametrize( + list(ParseCase._fields), + PARSE_CASES, + ids=[c.test_id for c in PARSE_CASES], +) +def test_read_result_round_trip( + test_id: str, + op: Operation[t.Any], + returncode: int, + stdout: tuple[str, ...], + expected: dict[str, t.Any], +) -> None: + """Every read result round-trips through its JSON-friendly dict form.""" + result = op.build_result(returncode=returncode, stdout=stdout) + assert result_from_dict(result_to_dict(result)) == result + + +def test_has_session_folds_stderr_to_stdout() -> None: + """A missing session's stderr is surfaced in stdout (engine-agnostic).""" + result = HasSession(target=SessionId("$9")).build_result( + returncode=1, + stderr=("can't find session: $9",), + ) + assert result.exists is False + assert result.stdout == ("can't find session: $9",) + assert result.stderr == ("can't find session: $9",) + + +def test_has_session_live(session: Session) -> None: + """has-session answers True for the fixture session, False for a fake one.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + assert session.session_id is not None + + present = run(HasSession(target=SessionId(session.session_id)), engine) + assert present.status == "complete" + assert present.exists is True + + absent = run(HasSession(target=NameRef("no-such-session-xyz")), engine) + assert absent.status == "complete" + assert absent.exists is False + + +def test_display_message_live(session: Session) -> None: + """display-message -p evaluates a format against a real pane.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + result = run( + DisplayMessage(target=PaneId(pane.pane_id), message="#{session_id}"), + engine, + ) + assert result.ok + assert result.text == session.session_id + + +def test_show_options_live(session: Session) -> None: + """show-options -g returns a non-empty option mapping.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + result = run(ShowOptions(global_=True), engine) + assert result.ok + assert result.options # global options are always present + + +def test_list_clients_live(session: Session) -> None: + """list-clients returns typed client snapshots (possibly none).""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + result = run(ListClients(), engine) + assert result.ok + assert all(c.name for c in result.clients) diff --git a/tests/experimental/ops/test_read_ops.py b/tests/experimental/ops/test_read_ops.py new file mode 100644 index 000000000..3a3ebb5eb --- /dev/null +++ b/tests/experimental/ops/test_read_ops.py @@ -0,0 +1,121 @@ +"""Tests for the read-seam list operations.""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + ListPanes, + ListSessions, + ListWindows, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + FORMAT_SEPARATOR, + get_output_format, +) +from libtmux.experimental.ops.results import ListPanesResult + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_list_panes_template_matches_neo() -> None: + """The op renders the identical -F template neo would build (no drift).""" + _fields, fmt = get_output_format("list-panes", "3.6a") + argv = ListPanes().render(version="3.6a") + assert "-a" in argv + assert fmt in argv # same template, byte for byte + + +def test_list_panes_parses_rows_into_snapshot_tree() -> None: + """A synthesized list-panes output parses into a ServerSnapshot tree.""" + fields, _ = get_output_format("list-panes", DEFAULT_LIST_VERSION) + + def row(**values: str) -> str: + cells = [values.get(name, "") for name in fields] + return FORMAT_SEPARATOR.join(cells) + FORMAT_SEPARATOR + + stdout = ( + row(session_id="$0", session_name="a", window_id="@1", pane_id="%1"), + row(session_id="$0", session_name="a", window_id="@1", pane_id="%2"), + ) + op = ListPanes() + result = op.build_result(returncode=0, stdout=stdout, version=DEFAULT_LIST_VERSION) + + assert isinstance(result, ListPanesResult) + assert [p.pane_id for p in result.panes] == ["%1", "%2"] + assert [s.session_id for s in result.server.sessions] == ["$0"] + assert [p.pane_id for p in result.server.sessions[0].windows[0].panes] == [ + "%1", + "%2", + ] + + +def test_list_result_serialization_round_trip() -> None: + """A list result round-trips via its JSON-friendly rows.""" + fields, _ = get_output_format("list-panes", DEFAULT_LIST_VERSION) + cells = [""] * len(fields) + cells[fields.index("pane_id")] = "%1" + line = FORMAT_SEPARATOR.join(cells) + FORMAT_SEPARATOR + result = ListPanes().build_result( + returncode=0, + stdout=(line,), + version=DEFAULT_LIST_VERSION, + ) + assert result_from_dict(result_to_dict(result)) == result + + +def test_empty_output_yields_empty_views() -> None: + """No panes -> empty rows, empty snapshot, no error.""" + result = run(ListPanes(), ConcreteEngine(), version="3.6a") + assert result.rows == () + assert result.server.sessions == () + assert result.ok + + +def test_list_panes_live(session: Session) -> None: + """Against real tmux, ListPanes builds a tree containing the fixture pane.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + engine = SubprocessEngine.for_server(server) + + # No version -> the safe 3.2a-floor template (a field subset valid on any + # supported tmux); core ids (pane_id/session_id) are always present. + result = run(ListPanes(), engine) + + assert result.ok + pane_ids = {p.pane_id for p in result.panes} + active_pane = session.active_pane + assert active_pane is not None + assert active_pane.pane_id in pane_ids + # the snapshot tree includes the fixture session + session_ids = {s.session_id for s in result.server.sessions} + assert session.session_id in session_ids + + +def test_list_sessions_live(session: Session) -> None: + """Against real tmux, ListSessions returns the fixture session.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + engine = SubprocessEngine.for_server(server) + result = run(ListSessions(), engine) + assert result.ok + assert session.session_id in {s.session_id for s in result.sessions} + + +def test_list_windows_live(session: Session) -> None: + """Against real tmux, ListWindows returns typed window snapshots.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + engine = SubprocessEngine.for_server(server) + result = run(ListWindows(), engine) + assert result.ok + assert all(w.window_id.startswith("@") for w in result.windows) diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py new file mode 100644 index 000000000..793e2aa82 --- /dev/null +++ b/tests/experimental/ops/test_registry.py @@ -0,0 +1,100 @@ +"""Tests for the operation registry.""" + +from __future__ import annotations + +import pytest + +from libtmux.experimental.ops import SplitWindow, registry +from libtmux.experimental.ops.exc import DuplicateOperation, UnknownOperation +from libtmux.experimental.ops.registry import OperationRegistry, OpSpec + + +def test_seed_operations_registered() -> None: + """All seed operations are present in the default registry.""" + assert set(registry.kinds()) >= { + "split_window", + "capture_pane", + "send_keys", + "select_layout", + } + + +def test_get_unknown_fails_closed() -> None: + """Looking up an unregistered kind raises :class:`UnknownOperation`.""" + with pytest.raises(UnknownOperation, match="does_not_exist"): + registry.get("does_not_exist") + + +def test_operation_lookup_returns_class() -> None: + """``operation`` returns the registered class for a kind.""" + assert registry.operation("split_window") is SplitWindow + + +def test_spec_from_operation_reads_classvars() -> None: + """An :class:`OpSpec` mirrors the operation's class variables.""" + spec = OpSpec.from_operation(SplitWindow) + assert spec.kind == "split_window" + assert spec.command == "split-window" + assert spec.scope == "window" + assert spec.result_cls is SplitWindow.result_cls + assert spec.effects.creates == "pane" + + +def test_list_predicate_filters() -> None: + """``list`` filters by a predicate and stays sorted by kind.""" + readonly = [ + spec.kind for spec in registry.select(lambda spec: spec.safety == "readonly") + ] + assert readonly == [ + "capture_pane", + "display_message", + "has_session", + "list_clients", + "list_panes", + "list_sessions", + "list_windows", + "show_buffer", + "show_options", + ] + + +@pytest.mark.parametrize( + "spec", + list(registry.select()), + ids=[spec.kind for spec in registry.select()], +) +def test_readonly_safety_implies_read_only_effect(spec: OpSpec) -> None: + """A ``safety == "readonly"`` op must declare ``effects.read_only``. + + The converse need not hold: an op can leave tmux state unchanged + (``read_only``) yet still be ``mutating`` because of an external side effect + -- e.g. ``save-buffer`` writes a file. + """ + if spec.safety == "readonly": + assert spec.effects.read_only + + +def test_register_duplicate_fails_closed() -> None: + """Registering an existing kind raises unless ``replace=True``.""" + local = OperationRegistry() + local.register(SplitWindow) + with pytest.raises(DuplicateOperation, match="split_window"): + local.register(SplitWindow) + local.register(SplitWindow, replace=True) + assert "split_window" in local + + +def test_unregister() -> None: + """Unregistering removes the kind; unregistering a missing kind raises.""" + local = OperationRegistry() + local.register(SplitWindow) + local.unregister("split_window") + assert "split_window" not in local + with pytest.raises(UnknownOperation): + local.unregister("split_window") + + +def test_len_and_iter() -> None: + """The default registry is sized and iterable in kind order.""" + assert len(registry) == len(registry.kinds()) + assert [spec.kind for spec in registry] == sorted(registry.kinds()) diff --git a/tests/experimental/ops/test_results.py b/tests/experimental/ops/test_results.py new file mode 100644 index 000000000..cfb43ebc2 --- /dev/null +++ b/tests/experimental/ops/test_results.py @@ -0,0 +1,73 @@ +"""Tests for results and the opt-in failure model.""" + +from __future__ import annotations + +import pytest + +from libtmux.experimental.ops import SendKeys +from libtmux.experimental.ops._types import PaneId +from libtmux.experimental.ops.exc import TmuxCommandError +from libtmux.experimental.ops.results import Result, status_for + + +@pytest.mark.parametrize( + ("returncode", "stderr", "expected"), + [ + pytest.param(0, [], "complete", id="clean"), + pytest.param(1, [], "failed", id="nonzero"), + pytest.param(0, ["no current session"], "failed", id="stderr-on-zero"), + ], +) +def test_status_for(returncode: int, stderr: list[str], expected: str) -> None: + """Tmux signalling failure via stderr counts as failed even on exit 0.""" + assert status_for(returncode, stderr) == expected + + +def _result(returncode: int, stderr: tuple[str, ...] = ()) -> Result: + """Build a send-keys result for the given outcome.""" + return SendKeys(target=PaneId("%1"), keys="x").build_result( + returncode=returncode, + stderr=stderr, + ) + + +def test_ok_result_does_not_raise() -> None: + """``raise_for_status`` returns the result itself when OK (fluent).""" + result = _result(0) + assert result.ok + assert result.raise_for_status() is result + + +def test_failed_result_raises_typed_error() -> None: + """A failed result raises :class:`TmuxCommandError` only when asked.""" + result = _result(1, ("can't find pane",)) + assert result.failed + with pytest.raises(TmuxCommandError) as excinfo: + result.raise_for_status() + assert excinfo.value.returncode == 1 + assert excinfo.value.stderr == ("can't find pane",) + + +def test_unknown_status_raises() -> None: + """An ``unknown`` (e.g. timeout) result also raises on demand.""" + base = _result(0) + unknown = Result( + operation=base.operation, + argv=base.argv, + status="unknown", + returncode=-1, + ) + with pytest.raises(TmuxCommandError): + unknown.raise_for_status() + + +def test_skipped_status_does_not_raise() -> None: + """A ``skipped`` operation is not a failure.""" + base = _result(0) + skipped = Result( + operation=base.operation, + argv=base.argv, + status="skipped", + returncode=0, + ) + assert skipped.raise_for_status() is skipped diff --git a/tests/experimental/ops/test_serialize.py b/tests/experimental/ops/test_serialize.py new file mode 100644 index 000000000..7c70e6520 --- /dev/null +++ b/tests/experimental/ops/test_serialize.py @@ -0,0 +1,106 @@ +"""Tests for operation/result serialization round-trips.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._types import ( + ClientName, + IndexRef, + NameRef, + PaneId, + SessionId, + Special, + WindowId, +) +from libtmux.experimental.ops.exc import UnknownOperation +from libtmux.experimental.ops.serialize import ( + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + target_from_dict, + target_to_dict, +) + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + +_OPERATIONS = [ + pytest.param( + SplitWindow( + target=PaneId("%1"), + horizontal=True, + start_directory="/tmp", + environment={"FOO": "bar"}, + ), + id="split_window-full", + ), + pytest.param(CapturePane(target=PaneId("%2"), start=0, end=10), id="capture_pane"), + pytest.param( + SendKeys(target=PaneId("%3"), keys="echo hi", enter=True), + id="send_keys", + ), + pytest.param( + SelectLayout(target=WindowId("@4"), layout="tiled"), id="select_layout" + ), + pytest.param(SplitWindow(), id="split_window-no-target"), +] + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_operation_round_trip(operation: Operation[t.Any]) -> None: + """An operation survives a dict round-trip unchanged.""" + assert operation_from_dict(operation_to_dict(operation)) == operation + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_operation_dict_is_plain_data(operation: Operation[t.Any]) -> None: + """A serialized operation holds only stable, JSON-friendly scalars.""" + data = operation_to_dict(operation) + assert data["kind"] == operation.kind + assert isinstance(data["target"], (dict, type(None))) + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_result_round_trip(operation: Operation[t.Any]) -> None: + """A result (with its operation and payload) survives a dict round-trip.""" + result = operation.build_result(returncode=0, stdout=("%9",)) + assert result_from_dict(result_to_dict(result)) == result + + +@pytest.mark.parametrize( + "target", + [ + pytest.param(PaneId("%1"), id="pane"), + pytest.param(WindowId("@1"), id="window"), + pytest.param(SessionId("$1"), id="session"), + pytest.param(ClientName("/dev/pts/1"), id="client"), + pytest.param(NameRef("work", exact=True), id="name"), + pytest.param(IndexRef(2, parent="$1"), id="index"), + pytest.param(Special("{marked}"), id="special"), + ], +) +def test_target_round_trip(target: Target) -> None: + """Every target type survives a dict round-trip.""" + assert target_from_dict(target_to_dict(target)) == target + + +def test_target_none_round_trip() -> None: + """A missing target round-trips as ``None``.""" + assert target_from_dict(target_to_dict(None)) is None + + +def test_from_dict_unknown_kind_fails_closed() -> None: + """Reviving an unregistered kind raises :class:`UnknownOperation`.""" + with pytest.raises(UnknownOperation): + operation_from_dict({"kind": "does_not_exist"}) diff --git a/tests/experimental/ops/test_types.py b/tests/experimental/ops/test_types.py new file mode 100644 index 000000000..3c72af786 --- /dev/null +++ b/tests/experimental/ops/test_types.py @@ -0,0 +1,82 @@ +"""Tests for the typed primitives in :mod:`libtmux.experimental.ops._types`.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops._types import ( + ClientName, + Effects, + IndexRef, + NameRef, + PaneId, + SessionId, + SlotRef, + Special, + WindowId, + render_target, +) + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Target + + +@pytest.mark.parametrize( + ("target", "expected"), + [ + pytest.param(PaneId("%1"), "%1", id="pane-id"), + pytest.param(WindowId("@2"), "@2", id="window-id"), + pytest.param(SessionId("$0"), "$0", id="session-id"), + pytest.param(ClientName("/dev/pts/3"), "/dev/pts/3", id="client-name"), + pytest.param(NameRef("work"), "work", id="name-ref"), + pytest.param(NameRef("work", exact=True), "=work", id="name-ref-exact"), + pytest.param(IndexRef(0), "0", id="index-ref"), + pytest.param(IndexRef(2, parent="$1"), "$1:2", id="index-ref-parent"), + pytest.param(Special("{marked}"), "{marked}", id="special"), + ], +) +def test_target_render(target: Target, expected: str) -> None: + """Each concrete target renders to its tmux ``-t`` token.""" + assert target.render() == expected + assert render_target(target) == expected + + +def test_render_target_none() -> None: + """``render_target(None)`` yields ``None`` (no target).""" + assert render_target(None) is None + + +@pytest.mark.parametrize( + ("ctor", "value"), + [ + pytest.param(PaneId, "1", id="pane-missing-sigil"), + pytest.param(WindowId, "2", id="window-missing-sigil"), + pytest.param(SessionId, "0", id="session-missing-sigil"), + pytest.param(ClientName, "", id="client-empty"), + pytest.param(NameRef, "", id="name-empty"), + pytest.param(Special, "", id="special-empty"), + ], +) +def test_target_validation_fails_closed( + ctor: t.Callable[[str], object], + value: str, +) -> None: + """Malformed targets raise at construction rather than at tmux time.""" + with pytest.raises(ValueError, match="must"): + ctor(value) + + +def test_slot_ref_render_raises() -> None: + """An unresolved deferred ref cannot render -- that is a planner bug.""" + with pytest.raises(TypeError, match="unresolved SlotRef"): + SlotRef(0).render() + + +def test_effects_defaults() -> None: + """An empty :class:`Effects` is all-false / no-creates.""" + effects = Effects() + assert not effects.read_only + assert not effects.destructive + assert effects.creates is None diff --git a/tests/experimental/ops/test_window_ops.py b/tests/experimental/ops/test_window_ops.py new file mode 100644 index 000000000..cd88e732c --- /dev/null +++ b/tests/experimental/ops/test_window_ops.py @@ -0,0 +1,226 @@ +"""Tests for the window mutation/navigation operations (bucket A).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + LastWindow, + LinkWindow, + MoveWindow, + NewWindow, + NextWindow, + PreviousWindow, + ResizeWindow, + RespawnWindow, + RotateWindow, + SelectWindow, + SplitWindow, + SwapWindow, + UnlinkWindow, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import IndexRef, SessionId, WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="select_window", + op=SelectWindow(target=WindowId("@1")), + expected=("select-window", "-t", "@1"), + ), + RenderCase( + test_id="last_window", + op=LastWindow(target=SessionId("$0")), + expected=("last-window", "-t", "$0"), + ), + RenderCase( + test_id="next_window", + op=NextWindow(target=SessionId("$0")), + expected=("next-window", "-t", "$0"), + ), + RenderCase( + test_id="next_window_alert", + op=NextWindow(target=SessionId("$0"), alert=True), + expected=("next-window", "-t", "$0", "-a"), + ), + RenderCase( + test_id="previous_window", + op=PreviousWindow(target=SessionId("$0")), + expected=("previous-window", "-t", "$0"), + ), + RenderCase( + test_id="resize_window_width", + op=ResizeWindow(target=WindowId("@1"), width=100), + expected=("resize-window", "-t", "@1", "-x100"), + ), + RenderCase( + test_id="rotate_window_up", + op=RotateWindow(target=WindowId("@1"), up=True), + expected=("rotate-window", "-t", "@1", "-U"), + ), + RenderCase( + test_id="respawn_window_kill", + op=RespawnWindow(target=WindowId("@1"), kill=True), + expected=("respawn-window", "-t", "@1", "-k"), + ), + RenderCase( + test_id="unlink_window_kill", + op=UnlinkWindow(target=WindowId("@1"), kill=True), + expected=("unlink-window", "-t", "@1", "-k"), + ), + RenderCase( + test_id="swap_window", + op=SwapWindow(target=WindowId("@1"), src_target=WindowId("@2")), + expected=("swap-window", "-t", "@1", "-s", "@2"), + ), + RenderCase( + test_id="move_window", + op=MoveWindow(target=SessionId("$0"), src_target=WindowId("@2")), + expected=("move-window", "-t", "$0", "-s", "@2"), + ), + RenderCase( + test_id="move_window_kill_renumber", + op=MoveWindow( + target=SessionId("$0"), + src_target=WindowId("@2"), + kill=True, + renumber=True, + ), + expected=("move-window", "-t", "$0", "-k", "-r", "-s", "@2"), + ), + RenderCase( + test_id="link_window", + op=LinkWindow(target=SessionId("$0"), src_target=WindowId("@2")), + expected=("link-window", "-t", "$0", "-s", "@2"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_window_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each window op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_window_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op (incl. its src_target) and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0) + assert result_from_dict(result_to_dict(result)) == result + + +def test_window_navigation_live(session: Session) -> None: + """select/next/previous/last-window move the active window.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + run(NewWindow(target=SessionId(sid)), engine).raise_for_status() + run(NewWindow(target=SessionId(sid)), engine).raise_for_status() + + session.refresh() + first = session.windows[0].window_id + assert first is not None + run(SelectWindow(target=WindowId(first)), engine).raise_for_status() + session.refresh() + assert session.active_window.window_id == first + + run(NextWindow(target=SessionId(sid)), engine).raise_for_status() + session.refresh() + assert session.active_window.window_id != first + + assert run(LastWindow(target=SessionId(sid)), engine).ok + assert run(PreviousWindow(target=SessionId(sid)), engine).ok + + +def test_resize_and_rotate_live(session: Session) -> None: + """resize-window and rotate-window succeed against a real window.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + + run(SplitWindow(target=WindowId(window.window_id)), engine).raise_for_status() + assert run(ResizeWindow(target=WindowId(window.window_id), width=90), engine).ok + assert run(RotateWindow(target=WindowId(window.window_id)), engine).ok + + +def test_swap_and_move_live(session: Session) -> None: + """swap-window swaps two windows; move-window relocates one by index.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + run(NewWindow(target=SessionId(sid)), engine).raise_for_status() + session.refresh() + first = session.windows[0].window_id + second = session.windows[1].window_id + assert first is not None and second is not None + + assert run( + SwapWindow(target=WindowId(first), src_target=WindowId(second)), + engine, + ).ok + assert run( + MoveWindow(target=IndexRef(9, parent=sid), src_target=WindowId(first)), + engine, + ).ok + + +def test_unlink_window_live(session: Session) -> None: + """unlink-window -k removes a window from its session.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + created = run(NewWindow(target=SessionId(sid)), engine) + created.raise_for_status() + new_id = created.new_id + assert new_id is not None + + assert run(UnlinkWindow(target=WindowId(new_id), kill=True), engine).ok + session.refresh() + assert session.windows.get(window_id=new_id, default=None) is None diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py new file mode 100644 index 000000000..20f0e0f65 --- /dev/null +++ b/tests/test_mcp_swap.py @@ -0,0 +1,120 @@ +"""The ported ``scripts/mcp_swap.py`` dev tool resolves this repo's identity. + +``mcp_swap`` swaps MCP server configs across agent CLIs to point at a local +checkout. The only port-specific change is the slug derivation: this repo's +package is ``libtmux`` but its MCP console script is ``libtmux-engine-mcp``, so +the slug must come from the *entry* (yielding ``libtmux-engine``) to stay +distinct from a sibling ``libtmux`` server. These tests lock that in, plus the +packaging wiring that makes the server runnable. +""" + +from __future__ import annotations + +import importlib.metadata +import importlib.util +import pathlib +import sys +import typing as t + +import pytest + +_REPO = pathlib.Path(__file__).resolve().parent.parent +_SCRIPT = _REPO / "scripts" / "mcp_swap.py" + + +def _load_mcp_swap() -> t.Any: + """Import the PEP 723 script as a module (registered so dataclasses resolve).""" + spec = importlib.util.spec_from_file_location("mcp_swap", _SCRIPT) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules["mcp_swap"] = module + spec.loader.exec_module(module) + return module + + +def test_console_script_registered() -> None: + """The ``libtmux-engine-mcp`` console script points at a loadable entry.""" + scripts = importlib.metadata.entry_points(group="console_scripts") + entry = next((ep for ep in scripts if ep.name == "libtmux-engine-mcp"), None) + assert entry is not None + assert entry.value == "libtmux.experimental.mcp:main" + + +def test_resolve_repo_meta_derives_engine_identity() -> None: + """Slug derives from the entry (``libtmux-engine``), not project.name.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + server, entry = mcp_swap.resolve_repo_meta(_REPO) + assert server == "libtmux-engine" + assert entry == "libtmux-engine-mcp" + + +def test_build_local_spec_uv_directory() -> None: + """``use-local`` writes a ``uv --directory run `` invocation.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + _, entry = mcp_swap.resolve_repo_meta(_REPO) + spec = mcp_swap.build_local_spec(_REPO, entry) + assert spec.command == "uv" + assert spec.args == ["--directory", str(_REPO), "run", "libtmux-engine-mcp"] + assert spec.is_local_uv_directory() + + +def test_grok_and_agy_registered() -> None: + """The grok and agy CLIs join the registry with their config shapes.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + assert "grok" in mcp_swap.ALL_CLIS + assert "agy" in mcp_swap.ALL_CLIS + assert mcp_swap.CLIS["grok"].fmt == "toml" + assert mcp_swap.CLIS["grok"].config_path.name == "config.toml" + assert mcp_swap.CLIS["agy"].fmt == "json" + assert mcp_swap.CLIS["agy"].config_path.name == "mcp_config.json" + + +def test_grok_set_get_delete_roundtrip() -> None: + """The grok CLI reads/writes the TOML ``[mcp_servers]`` table like codex.""" + tomlkit = pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + config = tomlkit.parse("") + spec = mcp_swap.McpServerSpec( + command="uv", args=["--directory", str(_REPO), "run", "x"] + ) + assert mcp_swap.set_server("grok", config, "tmux", spec, _REPO) == "added" + assert "mcp_servers" in config # TOML table, not the JSON "mcpServers" + got = mcp_swap.get_server("grok", config, "tmux", _REPO) + assert got is not None + assert got.is_local_uv_directory() + assert mcp_swap.set_server("grok", config, "tmux", spec, _REPO) == "replaced" + assert mcp_swap.delete_server("grok", config, "tmux", _REPO) + assert mcp_swap.get_server("grok", config, "tmux", _REPO) is None + + +def test_agy_set_get_delete_roundtrip() -> None: + """The agy CLI reads/writes the JSON ``mcpServers`` map like cursor/gemini.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + config: dict[str, t.Any] = {} + spec = mcp_swap.McpServerSpec( + command="uv", args=["--directory", str(_REPO), "run", "x"] + ) + assert mcp_swap.set_server("agy", config, "tmux", spec, _REPO) == "added" + # JSON (non-Claude) shape: no Claude-style "type", no empty "env" + assert "type" not in config["mcpServers"]["tmux"] + assert "env" not in config["mcpServers"]["tmux"] + got = mcp_swap.get_server("agy", config, "tmux", _REPO) + assert got is not None + assert got.is_local_uv_directory() + assert mcp_swap.delete_server("agy", config, "tmux", _REPO) + assert mcp_swap.get_server("agy", config, "tmux", _REPO) is None + + +def test_load_config_tolerates_empty_json(tmp_path: pathlib.Path) -> None: + """An empty JSON config (Antigravity's initial mcp_config.json) loads as {}.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + cfg = tmp_path / "mcp_config.json" + cfg.write_text("") + info = mcp_swap.CLIInfo(name="agy", binary="agy", config_path=cfg, fmt="json") + assert mcp_swap.load_config(info) == {} diff --git a/uv.lock b/uv.lock index 96f2ff9d7..239970643 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,10 @@ revision = 3 requires-python = ">=3.10, <4.0" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -42,6 +45,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "caio", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + +[[package]] +name = "aiofile" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "caio", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, +] + [[package]] name = "alabaster" version = "1.0.0" @@ -51,6 +88,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.14.0" @@ -105,6 +151,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -114,6 +182,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -127,6 +213,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -136,6 +260,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -393,6 +599,98 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/19/5c438b428b3dca208eb920804dc16aeb3ca1e85d6163d17e8fb0785ead19/cyclopts-4.18.0.tar.gz", hash = "sha256:fb7b730f21932e0784f7e54462df0447aaa1fbf034d65b605bd8a25dce58b188", size = 182821, upload-time = "2026-06-11T19:55:05.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/9f/b67f14c6b686ca90d317c0358f1a52ae171f43f83c808683fae3ba0b1f90/cyclopts-4.18.0-py3-none-any.whl", hash = "sha256:18ba2912e48e890a97ecc8a05c9beddf30a407b43f4e14cccfd40efddc41f029", size = 221216, upload-time = "2026-06-11T19:55:03.773Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -402,6 +700,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -423,6 +734,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "fastmcp" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/18/46beaec18c9f86a599ae3f9cdf6677dd6b50240cfd844d18233710b47f13/fastmcp-3.4.2.tar.gz", hash = "sha256:b468722946fc467c3796a6572f7a14d93d48c014cf8fea12910245220cbbe4e1", size = 28756849, upload-time = "2026-06-06T01:30:35.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/4d/8b1ba42251160e11ca34686344572121432c23a082d56ef6bbdec5888fc1/fastmcp-3.4.2-py3-none-any.whl", hash = "sha256:c87a62b029f0c5400ada85f683629345d2466c39169f0cb853e487b2f7308c08", size = 8018, upload-time = "2026-06-06T01:30:38.118Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2e/d627b28b7403ecc526991ef732921b08bde010006e6148635f053fd29f4c/fastmcp_slim-3.4.2.tar.gz", hash = "sha256:290646e0955a516235a317151034559aa48336cb843d3f006131aedad8759bb4", size = 576291, upload-time = "2026-06-06T01:30:12.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/58/22afebf18df7260b09148199cbeb90cdcc4b3a4e1b5d7460e3591c3a7add/fastmcp_slim-3.4.2-py3-none-any.whl", hash = "sha256:bdc72492212681ca502755fa8acc0457f559295da1fc3dfc0599adc1c04b82f3", size = 749195, upload-time = "2026-06-06T01:30:11.22Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, +] +server = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "joserfc" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pyperclip" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "starlette" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + [[package]] name = "gp-furo-theme" version = "0.0.1a31" @@ -483,6 +857,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/79/45ddf1d696179839699e550a47c3908e0ad6889d0968d5b0cc0e3b79419c/gp_sphinx-0.0.1a31-py3-none-any.whl", hash = "sha256:3377ebe1834204d402ebb8b4789ef1a8c80e4e9a7863a2c7abe29bef4755f862", size = 20134, upload-time = "2026-06-16T01:37:48.055Z" }, ] +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -492,6 +875,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -510,6 +930,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -519,6 +951,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -531,6 +1008,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/90/25cb27518750218e4f850be63d8bbb2343efaad1c01c3571aaa4b3c33bd7/joserfc-1.7.1.tar.gz", hash = "sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81", size = 233181, upload-time = "2026-06-08T07:21:33.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/00/fa62404c3e347f946faa13aa21085205f9cc06ad17671e37f81a51662ae8/joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164", size = 70423, upload-time = "2026-06-08T07:21:32.001Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -621,6 +1180,11 @@ name = "libtmux" version = "0.58.1" source = { editable = "." } +[package.optional-dependencies] +mcp = [ + { name = "fastmcp" }, +] + [package.dev-dependencies] coverage = [ { name = "codecov" }, @@ -630,6 +1194,7 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "fastmcp" }, { name = "gp-libs" }, { name = "gp-sphinx" }, { name = "mypy" }, @@ -644,6 +1209,8 @@ dev = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-pytest-fixtures" }, + { name = "tomlkit" }, + { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -660,15 +1227,19 @@ lint = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ + { name = "fastmcp" }, { name = "gp-libs" }, { name = "pytest" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "tomlkit" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] [package.metadata] +requires-dist = [{ name = "fastmcp", marker = "extra == 'mcp'", specifier = ">=3.4.2" }] +provides-extras = ["mcp"] [package.metadata.requires-dev] coverage = [ @@ -679,6 +1250,7 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "fastmcp" }, { name = "gp-libs" }, { name = "gp-sphinx", specifier = "==0.0.1a31" }, { name = "mypy" }, @@ -692,6 +1264,8 @@ dev = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a31" }, { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a31" }, + { name = "tomlkit" }, + { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -707,11 +1281,13 @@ lint = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ + { name = "fastmcp" }, { name = "gp-libs" }, { name = "pytest" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "tomlkit" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -748,7 +1324,10 @@ version = "4.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "mdurl", marker = "python_full_version >= '3.11'" }, @@ -843,6 +1422,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.6.1" @@ -865,6 +1469,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "mypy" version = "2.1.0" @@ -959,7 +1572,10 @@ version = "5.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "docutils", marker = "python_full_version >= '3.11'" }, @@ -974,6 +1590,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, ] +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -983,6 +1623,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathable" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -992,6 +1641,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1001,6 +1659,191 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "aiofile", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1010,6 +1853,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pytest" version = "9.1.0" @@ -1093,6 +1962,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1157,6 +2078,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1172,6 +2108,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-rst" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/56/3191bae66b08ccc637ea8120426068bcb361cc323c96404c310886937067/rich_rst-2.0.1.tar.gz", hash = "sha256:cbe236ed0901d1ec8427cc6a50bf0a34353ba28ad014dc24def68bfe7f3b9e68", size = 300570, upload-time = "2026-05-16T00:47:57.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3d/55c17d3ebdf3cd81356002afe5bef9bb8af631db2819785b6eac845b925b/rich_rst-2.0.1-py3-none-any.whl", hash = "sha256:7ee15f345ce25fa02b582c272a6cdbaf0c21243e38061cea273cff659bf3ef61", size = 272922, upload-time = "2026-05-16T00:47:55.508Z" }, +] + [[package]] name = "roman-numerals" version = "4.1.0" @@ -1193,6 +2156,275 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780", size = 4547, upload-time = "2025-12-17T18:25:40.136Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + [[package]] name = "ruff" version = "0.15.17" @@ -1218,6 +2450,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography", marker = "python_full_version < '3.11' or python_full_version >= '3.15' or sys_platform != 'win32'" }, + { name = "jeepney", marker = "python_full_version < '3.11' or python_full_version >= '3.15' or sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "snowballstemmer" version = "3.1.1" @@ -1273,7 +2518,10 @@ version = "8.2.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "alabaster", marker = "python_full_version >= '3.11'" }, @@ -1325,7 +2573,10 @@ version = "2025.8.25" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.11'" }, @@ -1432,7 +2683,10 @@ version = "0.7.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -1614,6 +2868,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/55/ab40a0d1378ee5c859590a633052cf1d0a1f8435af87558a9f7cd576601a/sphinxext_rediraffe-0.3.0-py3-none-any.whl", hash = "sha256:f4220beafa99c99177488276b8e4fcf61fbeeec4253c1e4aae841a18c475330c", size = 7194, upload-time = "2025-09-28T15:31:52.388Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -1681,6 +2948,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + +[[package]] +name = "ty" +version = "0.0.50" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/930ab48010e89fd1ecccc8f588afc9a79d540a1e8a379cf9cb3a41812254/ty-0.0.50.tar.gz", hash = "sha256:74b8c0df3e7d3294110e9862b7f8a3767f0e073dcb6ffa27f69fd63fd876149c", size = 5935862, upload-time = "2026-06-17T21:36:42.04Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/81/2161de593e722ba27d04ee394d974604e8041c2246e2f75012d612c9934a/ty-0.0.50-py3-none-linux_armv6l.whl", hash = "sha256:b04a7717c22b9c66e9161e5af608669194cdd099c5ba0c507aeb479e6c1f9176", size = 11917031, upload-time = "2026-06-17T21:37:20.615Z" }, + { url = "https://files.pythonhosted.org/packages/05/95/70c0f1915c91c9ed68b89a8f16741d73ad65628e334e1ae5691972003702/ty-0.0.50-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cd8204f3a8df8fe68581e0b978124a90a143f35e3e7a7725a6e247b5ce1dcb33", size = 11675310, upload-time = "2026-06-17T21:36:51.627Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6c/d0318ed6f52a6b184f6480137f5d1d3d6032fa81e4bd7eff495ccf4d2977/ty-0.0.50-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d336bbad38a68f16f16f84bb18f67215049b33196050c8ff67503e79619e70d2", size = 11060709, upload-time = "2026-06-17T21:36:47.226Z" }, + { url = "https://files.pythonhosted.org/packages/13/13/aff2242a51d66e4b99b71bb24081cf274b58db2909f82041ebd1f4bb2e35/ty-0.0.50-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3933810d0360a108c60cd3ea56e6c2eba2f5ecf7ee99d66ff30775d2ae9ed29", size = 11577192, upload-time = "2026-06-17T21:37:13.953Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c0/46ceb4dd1f6a8a89027e5af6bc7171301e545e7d179403c06e4067c24dee/ty-0.0.50-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:88eed477756c7a0280a38de60bcabc64b3c6dcea1ac8b2a41c9210896358f6b9", size = 11693109, upload-time = "2026-06-17T21:37:09.589Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9c/347e3d9959cd39641a54fc74dd4c152863f55c9079b9e16e9b4c35dd5775/ty-0.0.50-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caa5d1c76f75cb6d3105ec8bf835c0a8fbc0950ccf15e3d1e9c52cb99b0ab2f5", size = 12190755, upload-time = "2026-06-17T21:36:49.193Z" }, + { url = "https://files.pythonhosted.org/packages/85/ca/d8226604f57a8f1ead1973b01f2f8c987a60d0fc09f726cbc9a7ef074dad/ty-0.0.50-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d323f2a663e260923c11434655e95f37e04865b1e1641288a23cbcbca53074ee", size = 12761345, upload-time = "2026-06-17T21:36:55.81Z" }, + { url = "https://files.pythonhosted.org/packages/c4/23/0adcbd4676b30f6b58741ce2c1218c73988fdc8fb8c276005df2fe1817a3/ty-0.0.50-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d61cf42b8517e774354466252b085d83cdd0b51d771a63f99a4f21d5a44afc52", size = 12387850, upload-time = "2026-06-17T21:36:53.611Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/254fd544e95ec6f2368aab9cc9f7ffdf1329ad95d2c9f62dea22f986bee2/ty-0.0.50-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbcd0ee844fd551bd946bb9d4fd2baf39a12aa0ad2f9f64db48352a82758abb3", size = 12220520, upload-time = "2026-06-17T21:37:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/51/cd/711e649397e34d14f63d42586aebede0d4006a1a77c8c4c63bf4fb36bcbe/ty-0.0.50-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8acf55714ec075997edfbf4dfd7ba3241c18c773e96f41398bb6d8008b83751a", size = 12439071, upload-time = "2026-06-17T21:36:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/b5/48/743d3ff46307e904377ac264d6c365ef25fd6e36cbc12c10c73437fef3de/ty-0.0.50-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8b1e02ff71af62d7a1d9b8bfef98847f5c8bfd5bb8ae6da691cea405eb5a5e98", size = 11532092, upload-time = "2026-06-17T21:37:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/5589976874b04a62de124f8f79fc11a45207d27f1c1fd2e7b7ddfa55aeea/ty-0.0.50-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7781ace006ab6b8bb9a7591dc20d1aaa79549d9d4e8169e5dc5cf8eef0754cd7", size = 11706451, upload-time = "2026-06-17T21:37:02.5Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f4/e15ea290712e61528287a353cb3103b744a092ea0a59039f17a035960717/ty-0.0.50-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e8015a10f4caf07edc9245f178e64ea388bddb9fb8d2d73d2dc1cfe6b9790493", size = 11842752, upload-time = "2026-06-17T21:37:18.12Z" }, + { url = "https://files.pythonhosted.org/packages/f5/35/323b949d29cf6be2a71638498a1c43e4a2b84a1c1d206228cf3415384604/ty-0.0.50-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:87289648401648f455823334f2a8c67bbc341d502033591c8b044e67537e661b", size = 12325834, upload-time = "2026-06-17T21:37:16.124Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0a/5128b055493e41cb37f4f2a24a8ffce657e8c0d01086000c5f234d9b4622/ty-0.0.50-py3-none-win32.whl", hash = "sha256:ca73efc88be2942c1733e88b026f1cea88cafeca0ee63742dd673971d9a96642", size = 11171705, upload-time = "2026-06-17T21:36:45.191Z" }, + { url = "https://files.pythonhosted.org/packages/0b/65/91ffda139aa2b1d5c0fd321c859c5d10b7a912779d426422c4b25bca4362/ty-0.0.50-py3-none-win_amd64.whl", hash = "sha256:229d08c069beb2d896cc5556c3ba0e7f4c1b6d6a885297fabf2e6bcafa382a71", size = 12319493, upload-time = "2026-06-17T21:37:11.738Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4c/0c1ca628c5da7840e16801caa0bfeed1241e1113d8a5156a34245d4fa927/ty-0.0.50-py3-none-win_arm64.whl", hash = "sha256:96a84d970b59f2eddb92a4af3ba9906f24bda118cf487d923765ccd4ca24627b", size = 11635811, upload-time = "2026-06-17T21:37:07.191Z" }, +] + [[package]] name = "types-docutils" version = "0.22.3.20260518" @@ -1699,6 +3000,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "uc-micro-py" version = "2.0.0" @@ -1708,6 +3021,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] +[[package]] +name = "uncalled-for" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -1947,3 +3269,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]