diff --git a/CHANGES b/CHANGES index f7ba0efba..030d96e4b 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,20 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### New: chainable tmux commands (`libtmux._experimental.chain`) + +Build a sequence of tmux commands and run it in a single tmux call, +instead of one subprocess per command. References to panes, windows, +and sessions can be lazy — point at an object a command will create +and keep building against it — and they all resolve together when the +chain runs. Experimental and opt-in: import from +`libtmux._experimental.chain`, not the top-level `libtmux`. +When callers need per-command output, the experimental control-mode +runner batches command lines through one persistent `tmux -C` client +and returns one result per command. (#685) +`libtmux._experimental.chain`, not the top-level `libtmux`. (#685) ## libtmux 0.61.0 (2026-07-04) libtmux 0.61.0 hardens support for the tmux 3.7 patch line. It fixes diff --git a/docs/experiment/api/libtmux._experimental.chain._async.md b/docs/experiment/api/libtmux._experimental.chain._async.md new file mode 100644 index 000000000..6cbda1af6 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain._async.md @@ -0,0 +1,13 @@ +# Async - `libtmux._experimental.chain._async` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain._async + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain._connection.md b/docs/experiment/api/libtmux._experimental.chain._connection.md new file mode 100644 index 000000000..07bfe4531 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain._connection.md @@ -0,0 +1,13 @@ +# Connecting to live tmux sessions - `libtmux._experimental.chain._connection` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain._connection + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain.chain.md b/docs/experiment/api/libtmux._experimental.chain.chain.md new file mode 100644 index 000000000..308abb56a --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.chain.md @@ -0,0 +1,13 @@ +# Chain - `libtmux._experimental.chain.chain` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.chain + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain.control.md b/docs/experiment/api/libtmux._experimental.chain.control.md new file mode 100644 index 000000000..abbaf2ce8 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.control.md @@ -0,0 +1,13 @@ +# Control mode - `libtmux._experimental.chain.control` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.control + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain.ir.md b/docs/experiment/api/libtmux._experimental.chain.ir.md new file mode 100644 index 000000000..7d11ee5ee --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.ir.md @@ -0,0 +1,13 @@ +# Intermediate representation - `libtmux._experimental.chain.ir` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.ir + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain.plan.md b/docs/experiment/api/libtmux._experimental.chain.plan.md new file mode 100644 index 000000000..61bc53884 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.plan.md @@ -0,0 +1,13 @@ +# Expressions - `libtmux._experimental.chain.plan` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.plan + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/index.md b/docs/experiment/index.md new file mode 100644 index 000000000..226588834 --- /dev/null +++ b/docs/experiment/index.md @@ -0,0 +1,195 @@ +(experimental)= + +# Experimental + +:::{danger} +**No stability guarantee.** Everything under `libtmux._experimental` is **not** +covered by the project's versioning policy. It can change or be removed between +any releases without notice. + +These APIs are published so the design can be exercised and reviewed before any +stability commitment. If you depend on something here and want it stabilized, +please [file an issue](https://github.com/tmux-python/libtmux/issues). +::: + +## Chainable commands + +`libtmux._experimental.chain` lets you build an ordered sequence of +typed tmux commands that runs as **one** native `tmux ... \; ...` invocation, +instead of one subprocess per command. The pieces layer up, so you can reach for +as much or as little as you need: + +- **Intermediate representation** -- the typed argv layer beneath everything: a + {class}`~libtmux._experimental.chain.ir.CommandCall` is a single + command, and a + {class}`~libtmux._experimental.chain.ir.CommandChain` is an + ordered group that renders to one argv (with standalone `;` separators) and + dispatches once. +- **Expressions** -- compose commands from a lazy, target-safe pane query. A + {class}`~libtmux._experimental.chain.plan.PaneQuery` resolves + against a pure + {class}`~libtmux._experimental.chain.plan.TmuxSnapshot`, maps each + typed row to commands, and compiles to one sequence -- so you can build and + assert the result without touching tmux. +- **Async** -- {mod}`~libtmux._experimental.chain._async` mirrors the + same query and dispatch API with `await`, while command construction stays + synchronous and one expression still compiles to one invocation. +- **Connecting to live tmux sessions** -- the bridge to a real server: + {func}`~libtmux._experimental.chain._connection.snapshot_from_session` + reads live panes, and + {class}`~libtmux._experimental.chain._connection.SessionPlanExecutor` + (with its async counterpart + {class}`~libtmux._experimental.chain._connection.AsyncSessionPlanExecutor`) + resolves and runs an expression against a live {class}`~libtmux.Session` in one + invocation. +- **Control mode** -- + {class}`~libtmux._experimental.chain.control.ControlModeRunner` + batches command lines through one persistent `tmux -C` client and returns one + result per command when callers need per-command output. +- **Chainability** -- + {mod}`~libtmux._experimental.chain.chain` decides which commands + may share one invocation: the static + {attr}`~libtmux._experimental.chain.ir.CommandSpec.chainable` + flag, plus a deferred result that won't hand back output until the chain has + run. + +::::{grid} 1 2 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Intermediate representation +:link: api/libtmux._experimental.chain.ir +:link-type: doc +The typed argv layer: `CommandCall`, `CommandChain`, `CommandSpec`. +::: + +:::{grid-item-card} Expressions +:link: api/libtmux._experimental.chain.plan +:link-type: doc +Build commands from a lazy, target-safe pane query. +::: + +:::{grid-item-card} Async +:link: api/libtmux._experimental.chain._async +:link-type: doc +The same query and dispatch API, with `await`. +::: + +:::{grid-item-card} Connecting to live tmux sessions +:link: api/libtmux._experimental.chain._connection +:link-type: doc +Read live panes and run an expression against a real session. +::: + +:::{grid-item-card} Chainability +:link: api/libtmux._experimental.chain.chain +:link-type: doc +Which commands may share one invocation. +::: + +:::{grid-item-card} Control mode +:link: api/libtmux._experimental.chain.control +:link-type: doc +Batch command lines with per-command results. +::: + +:::: + +## At a glance + +Compose typed calls and dispatch them as one tmux invocation: + +```python +>>> from libtmux._experimental.chain.ir import CommandCall +>>> sequence = ( +... CommandCall("set-option", ("-g", "@cc_docs_a", "1")) +... >> CommandCall("set-option", ("-g", "@cc_docs_b", "2")) +... ) +>>> sequence.argv() +('set-option', '-g', '@cc_docs_a', '1', ';', 'set-option', '-g', '@cc_docs_b', '2') +>>> sequence.run(session.server).returncode +0 +>>> session.server.cmd("show-option", "-gv", "@cc_docs_b").stdout +['2'] +``` + +Build an expression from a query and compile it to one sequence -- pure, no tmux +required: + +```python +>>> from libtmux._experimental.chain.plan import ( +... PaneRef, +... PaneTarget, +... SessionTarget, +... TmuxSnapshot, +... WindowTarget, +... panes, +... ) +>>> snapshot = TmuxSnapshot( +... panes=( +... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", +... pane_index=0, active=True, title="editor"), +... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", +... pane_index=1, active=True, title="logs"), +... ), +... ) +>>> plan = ( +... panes() +... .filter(active=True) +... .order_by("pane_index") +... .commands(lambda pane: pane.cmd.resize_pane(height=20)) +... ) +>>> plan.to_chain(snapshot).argvs() +(('resize-pane', '-t', '%1', '-y', '20'), ('resize-pane', '-t', '%2', '-y', '20')) +``` + +Against a live server, run the same expression in one invocation with +{class}`~libtmux._experimental.chain._connection.SessionPlanExecutor`: + +```python +>>> from libtmux._experimental.chain import SessionPlanExecutor, panes +>>> runner = SessionPlanExecutor(session) +>>> live_plan = panes().filter(active=True).commands( +... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), +... ) +>>> live_plan.run(runner) +``` + +The same expression can be built and compiled asynchronously -- construction +stays synchronous; only resolution and dispatch await: + +```python +>>> import asyncio +>>> from libtmux._experimental.chain import aio +>>> from libtmux._experimental.chain.plan import ( +... PaneRef, +... PaneTarget, +... SessionTarget, +... TmuxSnapshot, +... WindowTarget, +... ) +>>> snapshot = TmuxSnapshot( +... panes=( +... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", +... pane_index=0, active=True, title="editor"), +... ), +... ) +>>> async def _resize() -> tuple[tuple[str, ...], ...]: +... plan = aio.panes().filter(active=True).commands( +... lambda pane: pane.cmd.resize_pane(height=20), +... ) +... return (await plan.to_chain(snapshot)).argvs() +>>> asyncio.run(_resize()) +(('resize-pane', '-t', '%1', '-y', '20'),) +``` + +```{toctree} +:hidden: +:maxdepth: 1 + +api/libtmux._experimental.chain.ir +api/libtmux._experimental.chain.plan +api/libtmux._experimental.chain._async +api/libtmux._experimental.chain._connection +api/libtmux._experimental.chain.chain +api/libtmux._experimental.chain.control +``` diff --git a/docs/index.md b/docs/index.md index 6da452c68..2f63bacd8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -99,6 +99,7 @@ topics/index api/index api/testing/index internals/index +experiment/index project/index history migration diff --git a/docs/project/public-api.md b/docs/project/public-api.md index ba7eae89a..26c229408 100644 --- a/docs/project/public-api.md +++ b/docs/project/public-api.md @@ -29,12 +29,16 @@ This includes: ## What Is Internal -Modules under `libtmux._internal` and `libtmux._vendor` are **not public**. -They may change or be removed without notice between any release. +Modules under `libtmux._internal`, `libtmux._vendor`, and +`libtmux._experimental` are **not public**. They may change or be removed +without notice between any release. `libtmux._experimental` additionally hosts +in-progress designs that are published for feedback before any stability +commitment (see {ref}`the experimental docs `). Do not import from: - `libtmux._internal.*` - `libtmux._vendor.*` +- `libtmux._experimental.*` ## Pre-1.0 Stability Policy diff --git a/pyproject.toml b/pyproject.toml index 24a2e4c5d..3d93348ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ dev = [ "pytest-mock", "pytest-watcher", "pytest-xdist", + "pytest-asyncio", # Coverage "codecov", "coverage", @@ -87,6 +88,7 @@ testing = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + "pytest-asyncio", ] coverage =[ "codecov", @@ -242,6 +244,8 @@ doctest_optionflags = [ "ELLIPSIS", "NORMALIZE_WHITESPACE" ] +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" testpaths = [ "src/libtmux", "tests", diff --git a/src/libtmux/_experimental/__init__.py b/src/libtmux/_experimental/__init__.py new file mode 100644 index 000000000..93fa87f98 --- /dev/null +++ b/src/libtmux/_experimental/__init__.py @@ -0,0 +1,13 @@ +"""Experimental libtmux APIs. + +Note +---- +This is an **experimental** namespace. Everything under +:mod:`libtmux._experimental` is **not** covered by the project's versioning +policy and may change or be removed between any releases without notice. + +If you depend on something here and want it stabilized, please +`file an issue `_. +""" + +from __future__ import annotations diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py new file mode 100644 index 000000000..07b410a94 --- /dev/null +++ b/src/libtmux/_experimental/chain/__init__.py @@ -0,0 +1,159 @@ +r"""Typed, chainable tmux command sequences (experimental). + +This package promotes the converged design from the ``chainable-commands`` +research into a typed, documented API. It lets callers author an ordered +sequence of tmux commands that compiles to **one** native ``tmux ... \\; ...`` +invocation and dispatches once, instead of issuing one subprocess per command. + +The layers build on each other: + +- :mod:`~libtmux._experimental.chain.ir` -- the immutable argv + intermediate representation (``CommandCall``, ``CommandChain``). +- :mod:`~libtmux._experimental.chain.plan` -- typed, target-safe + deferred query-command plans (``panes()``, ``CommandPlan``). +- :mod:`~libtmux._experimental.chain._async` -- an async facade over + the same engine, exposed publicly as ``aio`` (``aio.panes()``), preserving + one dispatch per plan. +- :mod:`~libtmux._experimental.chain._connection` -- live-tmux + connection helpers (``snapshot_from_session``, ``SessionPlanExecutor``, + ``AsyncSessionPlanExecutor``). +- :mod:`~libtmux._experimental.chain.chain` -- the chainability + contract that decides which commands may fold into one dispatch + (``CommandSpec.chainable`` + ``DeferredCommandResult``). + +Forward references come in two shapes; pick by whether the handles are +independent: + +- A **linear chain** -- ``PaneRef.split().split().do(...)`` in + :mod:`~libtmux._experimental.chain.plan`. Each step creates the object the next + step builds on (split a pane, then split *that* pane). Because tmux keeps the + freshly-created object active, the whole chain addresses it with no ``-t`` and + folds into **one** ``\\;`` invocation (``to_chain()`` / ``run()``). Use it when + the forward objects form a single line of descent. +- A **multi-handle plan** -- ``ForwardPlan``. Hands out + **independent** handles (two splits off one pane, two windows in a new + session) and resolves them over the minimum number of dispatches, capturing each + new id with ``-P -F`` and substituting it downstream. Use it when you hold more + than one forward object at once, or need a new id back + (``Resolved.bindings`` / ``Resolved.pane(...)``). + +A lone-pane ``ForwardPlan`` still folds to one dispatch (via the marked register), +so the two shapes overlap only there; reach for the linear chain for a pure line +of splits and ``ForwardPlan`` the moment the handles fan out. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. A ``\\;`` +sequence returns one merged result, so per-command output is not separable; reach +for individual typed calls (or ``run_deferred``) when you need a command's own +output. +""" + +from __future__ import annotations + +from libtmux._experimental.chain import _async as aio +from libtmux._experimental.chain._connection import ( + AsyncServerPlanRunner, + AsyncSessionPlanExecutor, + ServerPlanRunner, + SessionPlanExecutor, + snapshot_from_session, +) +from libtmux._experimental.chain._resolve import ( + ForwardDispatchError, + ForwardHandle, + ForwardPlan, + Resolved, +) +from libtmux._experimental.chain.chain import ( + ChainabilityError, + CommandScopeError, + DeferredCommandResult, + DeferredOutputUnavailable, + ensure_chainable, + is_chainable, + validate_command_scope, +) +from libtmux._experimental.chain.control import ( + ControlModeBlock, + ControlModeError, + ControlModeParser, + ControlModeResult, + ControlModeRunner, +) +from libtmux._experimental.chain.ir import ( + Arg, + CommandCall, + CommandChain, + CommandResultLike, + CommandRunner, + CommandScope, + CommandSpec, +) +from libtmux._experimental.chain.plan import ( + CommandPlan, + CommandValue, + ForwardDataUnavailable, + NoCommandsResolved, + PaneQuery, + PaneRef, + PaneTarget, + PendingTarget, + PlanRunner, + SessionRef, + SessionTarget, + TmuxSnapshot, + WindowRef, + WindowTarget, + new_session, + panes, +) + +__all__ = [ + "Arg", + "AsyncServerPlanRunner", + "AsyncSessionPlanExecutor", + "ChainabilityError", + "CommandCall", + "CommandChain", + "CommandPlan", + "CommandResultLike", + "CommandRunner", + "CommandScope", + "CommandScopeError", + "CommandSpec", + "CommandValue", + "ControlModeBlock", + "ControlModeError", + "ControlModeParser", + "ControlModeResult", + "ControlModeRunner", + "DeferredCommandResult", + "DeferredOutputUnavailable", + "ForwardDataUnavailable", + "ForwardDispatchError", + "ForwardHandle", + "ForwardPlan", + "NoCommandsResolved", + "PaneQuery", + "PaneRef", + "PaneTarget", + "PendingTarget", + "PlanRunner", + "Resolved", + "ServerPlanRunner", + "SessionPlanExecutor", + "SessionRef", + "SessionTarget", + "TmuxSnapshot", + "WindowRef", + "WindowTarget", + "aio", + "ensure_chainable", + "is_chainable", + "new_session", + "panes", + "snapshot_from_session", + "validate_command_scope", +] diff --git a/src/libtmux/_experimental/chain/_async.py b/src/libtmux/_experimental/chain/_async.py new file mode 100644 index 000000000..dc187e9bc --- /dev/null +++ b/src/libtmux/_experimental/chain/_async.py @@ -0,0 +1,299 @@ +r"""Asyncio facade over the deferred query-command plan. + +This is a thin wrapper over the sync +:mod:`~libtmux._experimental.chain.plan` engine: command +*construction* stays synchronous, and only snapshot resolution and command +dispatch become awaitable. A plan still compiles to exactly one +:class:`~libtmux._experimental.chain.ir.CommandChain`, so the +"one plan = one native ``\\;`` dispatch" guarantee is preserved -- it just runs +without blocking the event loop, and independent plans can resolve and dispatch +concurrently. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import dataclasses +import logging +import typing as t +from dataclasses import dataclass + +from libtmux._experimental.chain import plan as sync_plan +from libtmux._experimental.chain.chain import DeferredCommandResult +from libtmux._experimental.chain.ir import ( + Arg, + CommandChain, + CommandResultLike, +) + +logger = logging.getLogger(__name__) + +MappedT = t.TypeVar("MappedT") + +NoCommandsResolved = sync_plan.NoCommandsResolved + + +class AsyncCommandRunner(t.Protocol): + """Object capable of asynchronously dispatching one tmux command argv.""" + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command asynchronously.""" + ... + + +class AsyncSnapshotProvider(t.Protocol): + """Object that can asynchronously provide a pure tmux snapshot.""" + + async def snapshot(self) -> sync_plan.TmuxSnapshot: + """Return a tmux snapshot asynchronously.""" + ... + + +class AsyncPlanRunner(AsyncCommandRunner, AsyncSnapshotProvider, t.Protocol): + """A runner that can asynchronously resolve snapshots and dispatch.""" + + +AsyncSnapshotSource: t.TypeAlias = "sync_plan.TmuxSnapshot | AsyncSnapshotProvider" + + +@dataclass(frozen=True, slots=True) +class PaneQuery: + """An async lazy pane query backed by the sync query object. + + Construction stays synchronous; only :meth:`all`/:meth:`first` await a + snapshot source. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain.plan import ( + ... PaneRef, PaneTarget, SessionTarget, TmuxSnapshot, WindowTarget, + ... ) + >>> snapshot = TmuxSnapshot(panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... )) + >>> rows = asyncio.run(panes().filter(active=True).all(snapshot)) + >>> [row.pane_id.value for row in rows] + ['%1'] + """ + + query: sync_plan.PaneQuery + + def filter(self, *, active: bool) -> PaneQuery: + """Return a query filtered by active state. + + Examples + -------- + >>> panes().filter(active=True).query.active_filter + True + """ + return dataclasses.replace(self, query=self.query.filter(active=active)) + + def order_by(self, field: sync_plan.OrderField) -> PaneQuery: + """Return a query ordered by a known pane field. + + Examples + -------- + >>> panes().order_by("pane_index").query.ordering + 'pane_index' + """ + return dataclasses.replace(self, query=self.query.order_by(field)) + + def limit(self, count: int) -> PaneQuery: + """Return a query capped to ``count`` rows. + + Examples + -------- + >>> panes().limit(3).query.limit_count + 3 + """ + return dataclasses.replace(self, query=self.query.limit(count)) + + async def all(self, source: AsyncSnapshotSource) -> list[sync_plan.PaneRef]: + """Evaluate the query against an async snapshot source.""" + snapshot = await _resolve_snapshot(source) + return self.query.all(snapshot) + + async def first(self, source: AsyncSnapshotSource) -> sync_plan.PaneRef | None: + """Evaluate the query and return its first row, or ``None``.""" + snapshot = await _resolve_snapshot(source) + return self.query.first(snapshot) + + def map( + self, + mapper: t.Callable[[sync_plan.PaneRef], MappedT], + ) -> MappedPaneQuery[MappedT]: + """Return a data-only transformation query (no commands).""" + return MappedPaneQuery(query=self, mapper=mapper) + + def commands(self, mapper: sync_plan.CommandMapper) -> CommandPlan: + """Return a deferred async multi-command side-effect plan.""" + return CommandPlan(query=self, mapper=mapper) + + +@dataclass(frozen=True, slots=True) +class MappedPaneQuery(t.Generic[MappedT]): + """An async data-only query transformation over pane rows.""" + + query: PaneQuery + mapper: t.Callable[[sync_plan.PaneRef], MappedT] + + async def all(self, source: AsyncSnapshotSource) -> list[MappedT]: + """Evaluate the query and transform every row.""" + return [self.mapper(row) for row in await self.query.all(source)] + + async def first(self, source: AsyncSnapshotSource) -> MappedT | None: + """Evaluate the query and transform the first row, or ``None``.""" + row = await self.query.first(source) + if row is None: + return None + return self.mapper(row) + + +@dataclass(frozen=True, slots=True) +class CommandPlan: + """An async command plan that resolves a query into a command sequence. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain.plan import ( + ... PaneRef, PaneTarget, SessionTarget, TmuxSnapshot, WindowTarget, + ... ) + >>> snapshot = TmuxSnapshot(panes=( + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=True, title="logs"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... )) + >>> async def _demo(): + ... plan = ( + ... panes() + ... .filter(active=True) + ... .order_by("pane_index") + ... .commands(lambda pane: pane.cmd.resize_pane(height=20)) + ... ) + ... sequence = await plan.to_chain(snapshot) + ... return sequence.argvs() + >>> asyncio.run(_demo()) + (('resize-pane', '-t', '%1', '-y', '20'), ('resize-pane', '-t', '%2', '-y', '20')) + """ + + query: PaneQuery + mapper: sync_plan.CommandMapper + + async def to_chain(self, source: AsyncSnapshotSource) -> CommandChain: + """Resolve the async query and compile mapped commands. + + Reuses the sync compile path, so a plan still produces exactly one + :class:`~libtmux._experimental.chain.ir.CommandChain`. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain.plan import PaneRef, TmuxSnapshot + >>> snapshot = TmuxSnapshot(panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... )) + >>> async def _to_chain(): + ... plan = panes().commands(lambda p: p.cmd.resize_pane(height=10)) + ... return (await plan.to_chain(snapshot)).argvs() + >>> asyncio.run(_to_chain()) + (('resize-pane', '-t', '%1', '-y', '10'),) + """ + snapshot = await _resolve_snapshot(source) + return self.query.query.commands(self.mapper).to_chain(snapshot) + + async def run(self, runner: AsyncPlanRunner) -> None: + """Resolve, compile, and dispatch the plan in one async invocation. + + An empty plan is a no-op (it does not raise). + + Examples + -------- + Resolve and dispatch against a live session in one async invocation: + + >>> import asyncio + >>> from libtmux._experimental.chain import aio, AsyncSessionPlanExecutor + >>> plan = aio.panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> asyncio.run(plan.run(AsyncSessionPlanExecutor(session))) + """ + try: + sequence = await self.to_chain(runner) + except NoCommandsResolved: + return None + argv = sequence.argv() + logger.debug( + "tmux command sequence dispatched", + extra={"tmux_cmd": " ".join(argv), "tmux_subcommand": argv[0]}, + ) + result = await runner.cmd(argv[0], *argv[1:]) + logger.debug( + "tmux command sequence complete", + extra={"tmux_exit_code": result.returncode}, + ) + return None + + async def run_deferred( + self, + runner: AsyncPlanRunner, + ) -> tuple[DeferredCommandResult, ...]: + r"""Async: dispatch once and return a resolved deferred result per command. + + Mirrors :meth:`CommandPlan.run_deferred + `: one ``\\;`` + dispatch, each handle resolved with the chain's merged result. An empty + plan returns an empty tuple. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain import aio, AsyncSessionPlanExecutor + >>> plan = aio.panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> results = asyncio.run( + ... plan.run_deferred(AsyncSessionPlanExecutor(session)) + ... ) + >>> all(r.returncode == 0 for r in results) + True + """ + try: + sequence = await self.to_chain(runner) + except NoCommandsResolved: + return () + argv = sequence.argv() + result = await runner.cmd(argv[0], *argv[1:]) + return tuple( + DeferredCommandResult(call).resolve(result) for call in sequence.calls + ) + + +def panes() -> PaneQuery: + """Start an async lazy pane query. + + Examples + -------- + >>> panes().query + PaneQuery(active_filter=None, ordering=None, limit_count=None) + """ + return PaneQuery(sync_plan.panes()) + + +async def _resolve_snapshot(source: AsyncSnapshotSource) -> sync_plan.TmuxSnapshot: + if isinstance(source, sync_plan.TmuxSnapshot): + return source + return await source.snapshot() diff --git a/src/libtmux/_experimental/chain/_connection.py b/src/libtmux/_experimental/chain/_connection.py new file mode 100644 index 000000000..b2237a50c --- /dev/null +++ b/src/libtmux/_experimental/chain/_connection.py @@ -0,0 +1,248 @@ +"""Live-tmux connection helpers for chainable-commands plans. + +These bridge the typed plan layer to a real :class:`libtmux.Session`: +:func:`snapshot_from_session` reads live panes into a pure +:class:`~libtmux._experimental.chain.plan.TmuxSnapshot`, and +:class:`SessionPlanExecutor` satisfies +:class:`~libtmux._experimental.chain.plan.PlanRunner` so a +:class:`~libtmux._experimental.chain.plan.CommandPlan` resolves and +dispatches against an actual tmux server in one invocation. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux._experimental.chain.ir import ( + Arg, + CommandResultLike, + CommandRunner, +) +from libtmux._experimental.chain.plan import ( + PaneRef, + PaneTarget, + SessionTarget, + TmuxSnapshot, + WindowTarget, +) + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def snapshot_from_session(session: Session) -> TmuxSnapshot: + """Read a live session's panes into a pure snapshot. + + Parameters + ---------- + session : libtmux.Session + A live session to read panes from. + + Returns + ------- + TmuxSnapshot + Typed pane rows with their pane, window, and session targets. + + Examples + -------- + >>> snapshot = snapshot_from_session(session) + >>> len(snapshot.panes) >= 1 + True + >>> snapshot.panes[0].pane_id.value.startswith("%") + True + """ + rows: list[PaneRef] = [] + for pane in session.panes: + pane_id = pane.pane_id + window_id = pane.window_id + session_id = pane.session_id + # Fail closed: a missing id would render an empty ``-t ''`` target, + # which tmux resolves to the current/attached target. Skip the row + # rather than emit a target that silently mis-resolves. + if pane_id is None or window_id is None or session_id is None: + continue + rows.append( + PaneRef.concrete( + pane_id=PaneTarget(pane_id), + window_id=WindowTarget(window_id), + session_id=SessionTarget(session_id), + pane_index=int(pane.pane_index) if pane.pane_index is not None else 0, + active=pane.pane_active == "1", + title=pane.pane_title or "", + ), + ) + return TmuxSnapshot(panes=tuple(rows)) + + +class SessionPlanExecutor: + r"""A :class:`PlanRunner` backed by a live :class:`libtmux.Session`. + + Dispatches commands through ``session.server.cmd`` and resolves snapshots + via :func:`snapshot_from_session`, so a plan executes against real tmux in a + single native ``\\;`` invocation. + + Examples + -------- + >>> runner = SessionPlanExecutor(session) + >>> runner.snapshot().panes[0].pane_id.value.startswith("%") + True + + A plan resolves and dispatches once through the runner: + + >>> from libtmux._experimental.chain.plan import panes + >>> plan = panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> plan.run(runner) + """ + + def __init__(self, session: Session) -> None: + self.session = session + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command through the live server. + + Examples + -------- + >>> SessionPlanExecutor(session).cmd( + ... "set-option", "-g", "@cc_conn_demo", "1" + ... ).returncode + 0 + """ + # A live Server already satisfies the CommandRunner protocol; the cast + # keeps the variadic dispatch cleanly typed (mypy and ty both resolve + # ``Server.cmd`` to a union otherwise). + runner = t.cast("CommandRunner", self.session.server) + return runner.cmd(cmd, *args, target=target) + + def snapshot(self) -> TmuxSnapshot: + """Return a fresh snapshot of the session's panes. + + Examples + -------- + >>> SessionPlanExecutor(session).snapshot().panes[0].pane_id.value[0] + '%' + """ + return snapshot_from_session(self.session) + + +class AsyncSessionPlanExecutor: + """An ``AsyncPlanRunner`` backed by a live :class:`libtmux.Session`. + + libtmux dispatch is synchronous, so this offloads each blocking call to a + worker thread with :func:`asyncio.to_thread`. The plan still compiles to one + native sequence and dispatches once; it simply does not block the event + loop, so independent plans can resolve and dispatch concurrently. + + Examples + -------- + >>> import asyncio + >>> runner = AsyncSessionPlanExecutor(session) + >>> async def _demo() -> bool: + ... snapshot = await runner.snapshot() + ... return snapshot.panes[0].pane_id.value.startswith("%") + >>> asyncio.run(_demo()) + True + + A plan resolves and dispatches once, without blocking the loop: + + >>> from libtmux._experimental.chain import aio + >>> plan = aio.panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> asyncio.run(plan.run(runner)) + """ + + def __init__(self, session: Session) -> None: + self._sync = SessionPlanExecutor(session) + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command in a worker thread.""" + return await asyncio.to_thread(self._sync.cmd, cmd, *args, target=target) + + async def snapshot(self) -> TmuxSnapshot: + """Return a fresh snapshot, read in a worker thread.""" + return await asyncio.to_thread(self._sync.snapshot) + + +class ServerPlanRunner: + """A ``PlanRunner`` backed by a live :class:`libtmux.Server`. + + For create-from-scratch plans (``ForwardPlan().new_session(...)``) that have + no -- and need no -- pre-existing session: it dispatches straight through + ``server.cmd`` instead of borrowing an unrelated session's executor. + ``snapshot()`` is empty, since a server-level runner is for creation, not + query seeding -- a query-seeded plan still wants a :class:`SessionPlanExecutor`. + + Examples + -------- + >>> runner = ServerPlanRunner(server) + >>> runner.snapshot().panes + () + >>> runner.cmd("set-option", "-g", "@cc_srv_demo", "1").returncode + 0 + """ + + def __init__(self, server: Server) -> None: + self.server = server + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command through the live server.""" + runner = t.cast("CommandRunner", self.server) + return runner.cmd(cmd, *args, target=target) + + def snapshot(self) -> TmuxSnapshot: + """Return an empty snapshot (a server runner is for creation, not queries).""" + return TmuxSnapshot(panes=()) + + +class AsyncServerPlanRunner: + """An ``AsyncPlanRunner`` backed by a live :class:`libtmux.Server`. + + The async companion to :class:`ServerPlanRunner`; offloads the blocking + dispatch via :func:`asyncio.to_thread`. + + Examples + -------- + >>> import asyncio + >>> asyncio.run(AsyncServerPlanRunner(server).snapshot()).panes + () + """ + + def __init__(self, server: Server) -> None: + self._sync = ServerPlanRunner(server) + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command in a worker thread.""" + return await asyncio.to_thread(self._sync.cmd, cmd, *args, target=target) + + async def snapshot(self) -> TmuxSnapshot: + """Return an empty snapshot in a worker thread.""" + return await asyncio.to_thread(self._sync.snapshot) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py new file mode 100644 index 000000000..c290c927f --- /dev/null +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -0,0 +1,714 @@ +r"""Multi-dispatch resolution for independent forward handles (sans-I/O core). + +A *linear* forward chain folds into one ``tmux a \; b`` invocation: tmux +addresses the active (or single marked) object with no ``-t``. But it cannot +address two **independent** forward handles in one invocation -- ``-t`` is a +fixed argv token, and a freshly-created id escapes only as ``-P -F`` stdout. So +holding several independent handles needs **multiple dispatches**: each creation +runs on its own with ``-P -F '#{pane_id}'`` to capture its real id, which is +then substituted into the downstream commands. + +The resolution is a **sans-I/O generator** -- the same yield-request / +resume-with-result trampoline asyncio itself uses (``Future.__await__`` yields a +request, ``Task.__step`` ``.send()``\\s the result back). One core; two short +drivers (sync ``runner.cmd``, async ``await runner.cmd``). The N-dispatch logic +is never duplicated, and the generator is suspended at a ``yield`` between +dispatches, so it never blocks the event loop. + +Note +---- +This is an **experimental** prototype, not covered by the versioning policy. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass, field + +from libtmux._experimental.chain.chain import ensure_chainable +from libtmux._experimental.chain.ir import ( + CommandCall, + CommandChain, + CommandResultLike, + SlotRef, +) +from libtmux._experimental.chain.plan import ( + AnyTarget, + BoundPaneCommands, + BoundSessionCommands, + BoundWindowCommands, + PaneQuery, + PaneRef, + PaneTarget, + SessionRef, + SessionTarget, + WindowRef, + WindowTarget, + _target_arg, + _to_calls, +) + +if t.TYPE_CHECKING: + import collections.abc as cabc + + from libtmux._experimental.chain._async import AsyncPlanRunner + from libtmux._experimental.chain.plan import IntoCommands, PlanRunner + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + +# A live libtmux object, a chain ref, a typed target, or a bare id string. +PaneSeed: t.TypeAlias = "Pane | PaneRef | PaneTarget | str" +WindowSeed: t.TypeAlias = "Window | WindowRef | WindowTarget | str" +SessionSeed: t.TypeAlias = "Session | SessionRef | SessionTarget | str" + +Kind = t.Literal["pane", "window", "session"] +_CAPTURE: dict[Kind, str] = { + "pane": "#{pane_id}", + "window": "#{window_id}", + "session": "#{session_id}", +} +_SEED = -1 # reserved binding key for a query-resolved seed +_MARKED = "{marked}" # tmux's single server-wide marked-pane target token + + +class NoSeedResolved(RuntimeError): + """Raised when a query-seeded forward plan matches no pane.""" + + +class ForwardDispatchError(RuntimeError): + r"""A forward creation dispatch failed -- nonzero exit or no captured id. + + Raised by the resolver when a ``split``/``new-window``/``new-session`` did + not print the id it was asked to capture (tmux rejected the target, ran out + of space, etc.). It turns what was an opaque ``IndexError`` on empty stdout + into a clear failure carrying the offending ``argv`` and the tmux result. + + Examples + -------- + >>> class _Result: + ... stdout: list[str] = [] + ... stderr = ["no space for new pane"] + ... returncode = 1 + >>> err = ForwardDispatchError(("split-window", "-t", "%1"), _Result()) + >>> err.argv + ('split-window', '-t', '%1') + >>> print(str(err)) + forward dispatch failed (exit 1): split-window -t %1: no space for new pane + """ + + def __init__(self, argv: tuple[str, ...], result: CommandResultLike) -> None: + self.argv = argv + self.result = result + stderr = " ".join(result.stderr).strip() + detail = f": {stderr}" if stderr else "" + cmd = " ".join(argv) + msg = f"forward dispatch failed (exit {result.returncode}): {cmd}{detail}" + super().__init__(msg) + + +def _capture_id(argv: tuple[str, ...], result: CommandResultLike) -> str: + """Read the id a creation dispatch printed, or fail loudly.""" + if result.returncode != 0 or not result.stdout: + raise ForwardDispatchError(argv, result) + return result.stdout[0].strip() + + +# --- plan IR ---------------------------------------------------------------- +@dataclass(frozen=True, slots=True) +class _Create: + """A creation step; ``call.target`` is concrete, ``None``, or a parent SlotRef.""" + + slot: int + kind: Kind + call: CommandCall + + +@dataclass(frozen=True, slots=True) +class _Decorate: + """A decoration step; ``call.target`` may be a SlotRef into an earlier slot.""" + + call: CommandCall + + +_Step: t.TypeAlias = "_Create | _Decorate" + + +# --- the sans-I/O protocol -------------------------------------------------- +@dataclass(frozen=True, slots=True) +class SnapshotRequest: + """The driver must supply a tmux snapshot (sync or awaited).""" + + +@dataclass(frozen=True, slots=True) +class Dispatch: + """A tmux dispatch the driver runs, handing back its result. + + ``captures`` names the forward slot this dispatch creates an id for + (``None`` for a decorate- or cleanup-only dispatch); the core binds + ``stdout[0]`` to that slot once the driver returns the result. + """ + + argv: tuple[str, ...] + captures: int | None + + +Request: t.TypeAlias = "SnapshotRequest | Dispatch" + + +@dataclass(frozen=True, slots=True) +class Resolved: + """The outcome of a multi-dispatch resolution. + + ``bindings`` maps each forward slot to the concrete id tmux assigned + (``%N``/``@N``/``$N``); ``results`` holds each resolution dispatch's + result in order (a recovery ``select-pane -M`` after a failed marked + fold is not captured). + """ + + bindings: dict[int, str] = field(default_factory=dict) + results: tuple[CommandResultLike, ...] = () + + def pane(self, slot: int, server: Server) -> Pane: + """Look up the live pane a forward slot resolved to (by captured id).""" + pane = server.panes.get(pane_id=self.bindings[slot]) + assert pane is not None # get() raises ObjectDoesNotExist rather than None + return pane + + def window(self, slot: int, server: Server) -> Window: + """Look up the live window a forward slot resolved to (by captured id).""" + window = server.windows.get(window_id=self.bindings[slot]) + assert window is not None + return window + + def session(self, slot: int, server: Server) -> Session: + """Look up the live session a forward slot resolved to (by captured id).""" + session = server.sessions.get(session_id=self.bindings[slot]) + assert session is not None + return session + + +def _capturing(call: CommandCall, kind: Kind) -> CommandCall: + """Append ``-P -F '#{_id}'`` so the creation prints its stable id.""" + return dataclasses.replace(call, args=(*call.args, "-P", "-F", _CAPTURE[kind])) + + +def _with_capture(call: CommandCall, kind: Kind) -> tuple[str, ...]: + """Render a capturing creation as argv (the multi-dispatch per-step form).""" + return _capturing(call, kind).argv() + + +def _subst(call: CommandCall, bindings: dict[int, str]) -> CommandCall: + """Replace a SlotRef target with the captured concrete id (plus its suffix).""" + if isinstance(call.target, SlotRef): + resolved = bindings[call.target.slot] + call.target.suffix + return dataclasses.replace(call, target=resolved) + return call + + +# --- strategy: a lone pane handle folds into one {marked} dispatch ---------- +def _to_marked(call: CommandCall) -> CommandCall: + """Retarget a SlotRef call to tmux's ``{marked}`` register (single-dispatch).""" + if isinstance(call.target, SlotRef): + return dataclasses.replace(call, target=_MARKED) + return call + + +def _marked_eligible(steps: tuple[_Step, ...]) -> _Create | None: + """Return the lone pane creation when the plan folds into one dispatch. + + The marked register is a single server-wide slot, and only a non-detached + pane creation (``split-window``) leaves its result active to be marked. So + exactly one pane :class:`_Create` with no preceding decoration is the one + plan shape that resolves in a single ``{marked}`` invocation; any other shape + needs the multi-dispatch path. + """ + creates = [step for step in steps if isinstance(step, _Create)] + if len(creates) != 1 or creates[0].kind != "pane": + return None + create = creates[0] + for step in steps: + if step is create: + return create + if isinstance(step, _Decorate): + return None + return create + + +def _marked_invocation( + create: _Create, + decorates: tuple[CommandCall, ...], + bindings: dict[int, str], +) -> tuple[str, ...]: + r"""Fold a lone pane creation and its decorates into one ``\;`` invocation. + + Emits `` \; select-pane -m \; ... \; select-pane -M``: the split's new pane is active, ``-m`` + marks it, every decorate addresses it through tmux's ``{marked}`` register + (which resolves for window- and session-scoped decorates too), and a trailing + ``-M`` clears the register. Should the chain fail after the mark is set, + :func:`drive` issues a recovery ``-M``, so no server-wide mark leaks. With no + decorates only the capturing creation runs -- the mark would have no reader. + """ + capture = _capturing(_subst(create.call, bindings), create.kind) + if not decorates: + return capture.argv() + calls = [capture, CommandCall("select-pane", ("-m",))] + calls.extend(_to_marked(call) for call in decorates) + calls.append(CommandCall("select-pane", ("-M",))) + return CommandChain(tuple(calls)).argv() + + +def drive( + steps: tuple[_Step, ...], + *, + seed_query: PaneQuery | None = None, + allow_marked: bool = True, +) -> t.Generator[Request, t.Any, Resolved]: + r"""Sans-I/O resolution core: yield a :class:`Request`, resume via ``.send``. + + The plan shape picks the cheapest correct strategy (see :func:`_marked_eligible`): + a lone pane creation folds into **one** ``{marked}`` invocation; otherwise each + :class:`_Create` is dispatched alone with ``-P -F`` id capture and a run of + :class:`_Decorate`\\s folds into one trailing ``\;`` chain with the captured ids + substituted. No awaits, no runner, no threads -- a pure state machine the + sync/async drivers feed results into. ``allow_marked=False`` forbids the + single-dispatch ``{marked}`` fold, so the resolver never touches tmux's + server-wide marked pane. + """ + bindings: dict[int, str] = {} + results: list[CommandResultLike] = [] + tail: list[CommandCall] = [] + + if seed_query is not None: + snapshot = yield SnapshotRequest() + seed = seed_query.first(snapshot) + if seed is None: + msg = "query matched no pane to seed the forward plan" + raise NoSeedResolved(msg) + bindings[_SEED] = str(_target_arg(seed.pane_id)) + + solo = _marked_eligible(steps) if allow_marked else None + if solo is not None: + decorates = tuple(s.call for s in steps if isinstance(s, _Decorate)) + argv = _marked_invocation(solo, decorates, bindings) + result = yield Dispatch(argv, solo.slot) + if decorates and result.returncode != 0 and result.stdout: + yield Dispatch(("select-pane", "-M"), None) + bindings[solo.slot] = _capture_id(argv, result) + return Resolved(bindings, (result,)) + + for step in steps: + if isinstance(step, _Create): + if tail: + chain = CommandChain(tuple(_subst(c, bindings) for c in tail)) + results.append((yield Dispatch(chain.argv(), None))) + tail = [] + argv = _with_capture(_subst(step.call, bindings), step.kind) + result = yield Dispatch(argv, step.slot) + results.append(result) + bindings[step.slot] = _capture_id(argv, result) + else: + tail.append(step.call) + + if tail: + chain = CommandChain(tuple(_subst(c, bindings) for c in tail)) + results.append((yield Dispatch(chain.argv(), None))) + + return Resolved(bindings, tuple(results)) + + +# --- the two thin drivers (the only sync/async divergence) ------------------ +def run_sync( + gen: t.Generator[Request, t.Any, Resolved], runner: PlanRunner +) -> Resolved: + """Drive the resolution core with blocking calls.""" + try: + request = next(gen) + while True: + if isinstance(request, SnapshotRequest): + request = gen.send(runner.snapshot()) + else: + request = gen.send(runner.cmd(request.argv[0], *request.argv[1:])) + except StopIteration as stop: + return t.cast("Resolved", stop.value) + + +async def run_async( + gen: t.Generator[Request, t.Any, Resolved], + runner: AsyncPlanRunner, +) -> Resolved: + """Drive the *same* core with ``await`` -- no resolution logic duplicated.""" + try: + request = next(gen) + while True: + if isinstance(request, SnapshotRequest): + request = gen.send(await runner.snapshot()) + else: + result = await runner.cmd(request.argv[0], *request.argv[1:]) + request = gen.send(result) + except StopIteration as stop: + return t.cast("Resolved", stop.value) + + +# --- the builder + handles -------------------------------------------------- +def _location_args( + start_directory: str | None, environment: dict[str, str] | None +) -> tuple[str, ...]: + """Render create-time ``-c`` / ``-e=`` flags (as libtmux renders them). + + Examples + -------- + >>> _location_args("/tmp", {"FOO": "bar"}) + ('-c/tmp', '-eFOO=bar') + >>> _location_args(None, None) + () + """ + args: list[str] = [] + if start_directory is not None: + args.append(f"-c{start_directory}") + if environment: + args.extend(f"-e{key}={value}" for key, value in environment.items()) + return tuple(args) + + +def _split_args( + horizontal: bool, + shell: str | None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, +) -> tuple[str, ...]: + """Render the ``split-window`` flags shared by the plan- and handle-level split.""" + args = ["-h" if horizontal else "-v", *_location_args(start_directory, environment)] + if shell is not None: + args.append(shell) + return tuple(args) + + +def _id_of(obj: object, attr: str) -> str: + """Read a tmux id string from a live libtmux object, a chain ref, or a str. + + Examples + -------- + >>> _id_of("%1", "pane_id") + '%1' + >>> _id_of(PaneTarget("%2"), "pane_id") + '%2' + >>> _id_of(PaneRef.concrete(pane_id="%3", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title=""), "pane_id") + '%3' + """ + if isinstance(obj, str): + return obj + if isinstance(obj, (PaneTarget, WindowTarget, SessionTarget)): + return obj.value + value = getattr(obj, attr) + if isinstance(value, (PaneTarget, WindowTarget, SessionTarget)): + return value.value + return str(value) + + +def _kind_of(target: AnyTarget) -> Kind: + """Return the tmux scope a concrete seed target addresses.""" + if isinstance(target, PaneTarget): + return "pane" + if isinstance(target, WindowTarget): + return "window" + if isinstance(target, SessionTarget): + return "session" + msg = f"cannot seed a forward plan from {type(target).__name__}" + raise TypeError(msg) + + +class ForwardHandle: + """A reference to one object inside a :class:`ForwardPlan` -- forward or seed. + + One type spans all three tmux scopes and both lifetimes: a *forward* handle + is bound to a :class:`~libtmux._experimental.chain.ir.SlotRef` (a not-yet- + created object whose id the resolver substitutes); a *seed* handle is bound + to a concrete id string (an object that already exists). The handle knows its + ``kind`` so creation verbs stay scope-correct -- ``new_window()`` only on a + session, ``split()`` only on a pane or window -- while the ``.cmd``/ + ``.window``/``.session`` command namespaces are reused unchanged. + """ + + def __init__(self, plan: ForwardPlan, ref: SlotRef | str, kind: Kind) -> None: + self._plan = plan + self._ref = ref + self._kind = kind + + @property + def cmd(self) -> BoundPaneCommands: + """Pane-scoped commands bound to this handle.""" + ref = self._ref if isinstance(self._ref, SlotRef) else PaneTarget(self._ref) + return BoundPaneCommands(ref) + + @property + def window(self) -> BoundWindowCommands: + """Window-scoped commands (a pane id resolves up to its window).""" + ref = self._ref if isinstance(self._ref, SlotRef) else WindowTarget(self._ref) + return BoundWindowCommands(ref) + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this handle's session.""" + ref = self._ref if isinstance(self._ref, SlotRef) else SessionTarget(self._ref) + return BoundSessionCommands(ref) + + def _parent(self, suffix: str = "") -> str | int | None | SlotRef: + """Return the ``-t`` parent for a creation off this handle (slot or id).""" + if isinstance(self._ref, SlotRef): + return SlotRef(self._ref.slot, suffix) + return f"{self._ref}{suffix}" if suffix else self._ref + + def split( + self, + *, + horizontal: bool = False, + shell: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + ) -> ForwardHandle: + """Split this handle's active pane; return a handle to the new pane.""" + self._require("split", "pane", "window") + return self._plan._create( + self._parent(), + "pane", + "split-window", + _split_args(horizontal, shell, start_directory, environment), + ) + + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + window_shell: str | None = None, + ) -> ForwardHandle: + """Create a window in this session handle; return a window handle. + + Targets the session as ``-t $N:`` -- the (captured or concrete) session + id with a ``:`` suffix, so it addresses a new window in that session. + """ + self._require("new_window", "session") + args: list[str] = [] + if name is not None: + args += ["-n", name] + args.extend(_location_args(start_directory, environment)) + if window_shell is not None: + args.append(window_shell) + return self._plan._create( + self._parent(":"), "window", "new-window", tuple(args) + ) + + @property + def initial_pane(self) -> ForwardHandle: + """Return a pane handle on this session's initial (default) pane. + + A detached ``new-session`` is born with one window and pane that the + plan otherwise can't address; this hands back a pane handle bound to the + session (which resolves to its active pane), so the default window can be + decorated or split instead of orphaned. Session handles only. + """ + if self._kind != "session": + msg = f"initial_pane is only on a session handle, not a {self._kind} handle" + raise TypeError(msg) + return ForwardHandle(self._plan, self._ref, "pane") + + @property + def initial_window(self) -> ForwardHandle: + """Return a window handle on this session's initial (default) window.""" + if self._kind != "session": + msg = ( + f"initial_window is only on a session handle, not a {self._kind} handle" + ) + raise TypeError(msg) + return ForwardHandle(self._plan, self._ref, "window") + + def do(self, build: cabc.Callable[[ForwardHandle], IntoCommands]) -> ForwardHandle: + """Decorate this handle via its namespaces (reused, no new vocabulary).""" + calls = _to_calls(build(self)) + for call in calls: + ensure_chainable(call.name) + self._plan._steps.extend(_Decorate(call) for call in calls) + return self + + def _require(self, verb: str, *kinds: Kind) -> None: + """Reject a creation verb used on a handle of the wrong tmux scope.""" + if self._kind not in kinds: + allowed = " or ".join(kinds) + msg = f"{verb}() needs a {allowed} handle, not a {self._kind} handle" + raise TypeError(msg) + + +class ForwardPlan: + r"""A builder for a multi-handle forward plan, resolved over N dispatches. + + Hand out independent handles across every tmux scope -- :meth:`new_session`, + then :meth:`ForwardHandle.new_window`, then :meth:`split` -- decorate each + through its reused namespaces, then :meth:`run_resolving` (sync) or + :meth:`run_resolving_async`: one creation per dispatch (``-P -F`` capture), + the downstream commands folded into one trailing ``\;`` chain with the + captured ids substituted in. + + Examples + -------- + Two independent panes, resolved over three dispatches against a fake server + that hands back fabricated pane ids: + + >>> from libtmux._experimental.chain.plan import PaneTarget + >>> class _FakeServer: + ... count = 6 + ... def cmd(self, *argv): + ... _FakeServer.count += 1 + ... line = [f"%{_FakeServer.count}"] + ... return type("R", (), {"stdout": line, "stderr": [], "returncode": 0})() + >>> plan = ForwardPlan(PaneTarget("%1")) + >>> left, right = plan.split(horizontal=True), plan.split() + >>> _ = left.do(lambda h: h.cmd.send_keys("vim", enter=True)) + >>> _ = right.do(lambda h: h.cmd.send_keys("htop", enter=True)) + >>> plan.run_resolving(_FakeServer()).bindings + {0: '%7', 1: '%8'} + """ + + def __init__(self, seed: AnyTarget | None = None) -> None: + self._steps: list[_Step] = [] + self._n = 0 + self._seed = seed + self._seed_query: PaneQuery | None = None + + @classmethod + def from_pane(cls, pane: PaneSeed) -> ForwardPlan: + """Seed from an existing pane (a live ``Pane``, a ref, a target, or an id).""" + return cls(seed=PaneTarget(_id_of(pane, "pane_id"))) + + @classmethod + def from_window(cls, window: WindowSeed) -> ForwardPlan: + """Seed from an existing window -- ``split`` splits its active pane.""" + return cls(seed=WindowTarget(_id_of(window, "window_id"))) + + @classmethod + def from_session(cls, session: SessionSeed) -> ForwardPlan: + """Seed from an existing session -- ``new_window`` adds windows to it.""" + return cls(seed=SessionTarget(_id_of(session, "session_id"))) + + @classmethod + def from_query(cls, query: PaneQuery) -> ForwardPlan: + """Seed the plan from the first row of a live query (read at run time).""" + plan = cls(seed=None) + plan._seed_query = query + return plan + + @property + def seed(self) -> ForwardHandle: + """A handle to the existing seed object -- decorate it or create off it. + + Lets the pre-existing seed take part in the plan like a created handle: + ``plan.seed.do(...)`` decorates it, and (by scope) ``plan.seed.split()`` / + ``plan.seed.new_window()`` create children of it. + """ + if self._seed is None: + msg = "plan has no concrete seed (use from_pane/from_window/from_session)" + raise ValueError(msg) + return ForwardHandle(self, str(_target_arg(self._seed)), _kind_of(self._seed)) + + def _seed_target(self) -> str | int | None | SlotRef: + if self._seed_query is not None: + return SlotRef(_SEED) + if self._seed is None: + return None + return _target_arg(self._seed) + + def _create( + self, + parent: str | int | None | SlotRef, + kind: Kind, + name: str, + args: tuple[str, ...], + ) -> ForwardHandle: + slot = self._n + self._n += 1 + self._steps.append(_Create(slot, kind, CommandCall(name, args, target=parent))) + return ForwardHandle(self, SlotRef(slot), kind) + + def split( + self, + *, + horizontal: bool = False, + shell: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + ) -> ForwardHandle: + """Split the seed (root); return a handle to the new pane.""" + return self._create( + self._seed_target(), + "pane", + "split-window", + _split_args(horizontal, shell, start_directory, environment), + ) + + def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + width: int | None = None, + height: int | None = None, + ) -> ForwardHandle: + """Create a detached session; return a session handle.""" + args: list[str] = ["-d"] + if name is not None: + args += ["-s", name] + args.extend(_location_args(start_directory, environment)) + if width is not None: + args += ["-x", str(width)] + if height is not None: + args += ["-y", str(height)] + return self._create(None, "session", "new-session", tuple(args)) + + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + window_shell: str | None = None, + ) -> ForwardHandle: + """Create a window in the seed session (requires :meth:`from_session`).""" + return self.seed.new_window( + name=name, + start_directory=start_directory, + environment=environment, + window_shell=window_shell, + ) + + def run_resolving( + self, runner: PlanRunner, *, preserve_mark: bool = False + ) -> Resolved: + """Resolve over N dispatches against a live server (sync). + + ``preserve_mark=True`` skips the single-dispatch ``{marked}`` fold (which + transiently sets then clears tmux's server-wide marked pane), so resolving + against a user's live server does not clobber a mark they had set. + """ + gen = drive( + tuple(self._steps), + seed_query=self._seed_query, + allow_marked=not preserve_mark, + ) + return run_sync(gen, runner) + + async def run_resolving_async( + self, runner: AsyncPlanRunner, *, preserve_mark: bool = False + ) -> Resolved: + """Resolve over N dispatches against a live server (async, same core).""" + gen = drive( + tuple(self._steps), + seed_query=self._seed_query, + allow_marked=not preserve_mark, + ) + return await run_async(gen, runner) diff --git a/src/libtmux/_experimental/chain/chain.py b/src/libtmux/_experimental/chain/chain.py new file mode 100644 index 000000000..f2319442c --- /dev/null +++ b/src/libtmux/_experimental/chain/chain.py @@ -0,0 +1,222 @@ +"""The chainability contract: what may fold into one dispatch. + +A tmux command sequence is dispatched once, so a command may only join a chain +if its output is **not** consumed mid-chain. This module wires the two halves of +that rule together: + +- *static* -- a :class:`~libtmux._experimental.chain.ir.CommandSpec` + per command declares ``chainable`` (see :data:`COMMAND_SPECS` / + :func:`is_chainable`). Output commands such as ``show-option`` are + ``chainable=False``. +- *dynamic* -- :class:`DeferredCommandResult` stands in for a call folded into a + chain. It raises :class:`DeferredOutputUnavailable` if its output is read + before the chain runs, and resolves to the chain's merged result afterwards. + :meth:`~libtmux._experimental.chain.plan.CommandPlan.run_deferred` (and its + async counterpart) dispatch once and hand back resolved deferred results. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux._experimental.chain.ir import ( + CommandCall, + CommandResultLike, + CommandScope, + CommandSpec, +) + +COMMAND_SPECS: dict[str, CommandSpec] = { + "new-session": CommandSpec("new-session", "server"), + "new-window": CommandSpec("new-window", "session"), + "split-window": CommandSpec("split-window", "pane"), + "break-pane": CommandSpec("break-pane", "pane"), + "rename-window": CommandSpec("rename-window", "window"), + "rename-session": CommandSpec("rename-session", "session"), + "select-layout": CommandSpec("select-layout", "window"), + "select-pane": CommandSpec("select-pane", "pane"), + "select-window": CommandSpec("select-window", "window"), + "send-keys": CommandSpec("send-keys", "pane"), + "resize-pane": CommandSpec("resize-pane", "pane"), + "set-option": CommandSpec("set-option", "server"), + "set-environment": CommandSpec("set-environment", "session"), + # Output commands cannot fold into a chain -- they need stdout immediately. + "show-option": CommandSpec("show-option", "server", chainable=False), + "capture-pane": CommandSpec("capture-pane", "pane", chainable=False), + "display-message": CommandSpec("display-message", "server", chainable=False), +} +"""Known command metadata, including each command's ``chainable`` flag.""" + + +COMMAND_TARGET_SCOPES: dict[str, frozenset[CommandScope]] = { + "display-message": frozenset(("server", "session", "window", "pane")), + "set-option": frozenset(("server", "session", "window", "pane")), + "show-option": frozenset(("server", "session", "window", "pane")), + "split-window": frozenset(("window", "pane")), +} +"""Commands whose accepted typed target scopes differ from their primary scope.""" + + +class DeferredOutputUnavailable(RuntimeError): + """Raised when a deferred command result is inspected before dispatch.""" + + +class ChainabilityError(RuntimeError): + """Raised when a non-chainable command is added to a chain.""" + + +class CommandScopeError(RuntimeError): + """Raised when a known command is bound to the wrong typed target scope.""" + + +def is_chainable(name: str) -> bool: + """Return whether a command may fold into a one-dispatch chain. + + Unknown commands fail closed. Commands in :data:`COMMAND_SPECS` use their + declared ``chainable`` flag. + + Examples + -------- + >>> is_chainable("rename-window") + True + >>> is_chainable("show-option") + False + >>> is_chainable("some-unknown-command") + False + """ + spec = COMMAND_SPECS.get(name) + if spec is None: + return False + return spec.chainable + + +def ensure_chainable(name: str) -> None: + """Raise unless ``name`` is a known command that may fold into a chain. + + Examples + -------- + >>> ensure_chainable("rename-window") + >>> ensure_chainable("show-option") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + libtmux...ChainabilityError: command 'show-option' is not chainable... + >>> ensure_chainable("some-unknown-command") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + libtmux...ChainabilityError: unknown tmux command 'some-unknown-command'... + """ + spec = COMMAND_SPECS.get(name) + if spec is None: + msg = ( + f"unknown tmux command {name!r} cannot be folded into " + "a one-dispatch sequence" + ) + raise ChainabilityError(msg) + if not spec.chainable: + msg = ( + f"command {name!r} is not chainable and cannot be " + f"folded into a one-dispatch sequence" + ) + raise ChainabilityError(msg) + + +def validate_command_scope(name: str, target_scope: CommandScope) -> None: + """Raise if a known command cannot target ``target_scope``. + + Unknown commands are left to :func:`ensure_chainable`; raw + :class:`CommandCall` targets are opaque and intentionally not parsed. + + Examples + -------- + >>> validate_command_scope("rename-window", "window") + >>> validate_command_scope("rename-window", "pane") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + libtmux...CommandScopeError: command 'rename-window' cannot target pane... + >>> validate_command_scope("some-unknown-command", "pane") + """ + spec = COMMAND_SPECS.get(name) + if spec is None: + return + allowed = COMMAND_TARGET_SCOPES.get(name, frozenset((spec.scope,))) + if target_scope not in allowed: + msg = f"command {name!r} cannot target {target_scope} scope" + raise CommandScopeError(msg) + + +@dataclass(frozen=True, slots=True) +class DeferredCommandResult: + r"""A result handle for a call folded into a one-dispatch chain. + + A chained call has no result of its own until the chain runs: a ``\\;`` + sequence dispatches once and returns a single merged result. While + ``result`` is ``None`` the handle is *unresolved* and reading output raises + :class:`DeferredOutputUnavailable`; once resolved it hands back the chain's + merged result -- the same result for every call in the chain, since a + ``\\;`` dispatch is not separable per command. + + Examples + -------- + Unresolved -- the value does not exist yet: + + >>> pending = DeferredCommandResult(CommandCall("rename-window", ("work",))) + >>> try: + ... pending.stdout + ... except DeferredOutputUnavailable: + ... print("unavailable until the chain runs") + unavailable until the chain runs + + Resolved -- the chain's merged result is handed back: + + >>> class _Merged: + ... stdout, stderr, returncode = [], [], 0 + >>> done = pending.resolve(_Merged()) + >>> done.returncode + 0 + """ + + call: CommandCall + result: CommandResultLike | None = None + + def resolve(self, result: CommandResultLike) -> DeferredCommandResult: + """Return a resolved copy bound to the chain's merged result. + + Examples + -------- + >>> class _Merged: + ... stdout, stderr, returncode = ["ok"], [], 0 + >>> DeferredCommandResult( + ... CommandCall("rename-window", ("work",)) + ... ).resolve(_Merged()).stdout + ['ok'] + """ + return DeferredCommandResult(self.call, result) + + @property + def stdout(self) -> list[str]: + """Chain stdout once resolved; otherwise reject.""" + if self.result is None: + msg = "deferred command output is unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + return self.result.stdout + + @property + def stderr(self) -> list[str]: + """Chain stderr once resolved; otherwise reject.""" + if self.result is None: + msg = "deferred command errors are unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + return self.result.stderr + + @property + def returncode(self) -> int: + """Chain return code once resolved; otherwise reject.""" + if self.result is None: + msg = "deferred command status is unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + return self.result.returncode diff --git a/src/libtmux/_experimental/chain/control.py b/src/libtmux/_experimental/chain/control.py new file mode 100644 index 000000000..6ffb007ba --- /dev/null +++ b/src/libtmux/_experimental/chain/control.py @@ -0,0 +1,475 @@ +"""Control-mode runner for experimental chain commands. + +This module keeps the control-mode surface scoped to +``libtmux._experimental.chain``. It borrows the persistent ``tmux -C`` + +batched-stdin shape from the protocol-engines experiments without installing a +general engine registry into this branch. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import logging +import os +import selectors +import shlex +import shutil +import subprocess +import threading +import time +import typing as t + +from libtmux import exc +from libtmux._experimental.chain.ir import Arg, CommandCall, CommandChain + +if t.TYPE_CHECKING: + import types + from collections.abc import Sequence + + from libtmux.server import Server + +logger = logging.getLogger(__name__) + +_BEGIN_PREFIX = b"%begin " +_END_PREFIX = b"%end " +_ERROR_PREFIX = b"%error " +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_GRACEFUL_EXIT_TIMEOUT = 0.5 +_TERMINATE_TIMEOUT = 1.0 +_NOTIFICATION_PREFIXES = ( + b"%extended-output ", + b"%output ", + b"%pause ", + b"%continue ", + b"%session-changed ", + b"%client-session-changed ", + b"%session-renamed ", + b"%sessions-changed", + b"%session-window-changed ", + b"%window-add ", + b"%window-close ", + b"%window-renamed ", + b"%window-pane-changed ", + b"%pane-mode-changed ", + b"%unlinked-window-add ", + b"%unlinked-window-close ", + b"%unlinked-window-renamed ", + b"%paste-buffer-changed ", + b"%paste-buffer-deleted ", + b"%client-detached ", + b"%subscription-changed ", + b"%exit", + b"%message ", +) + + +class ControlModeError(exc.LibTmuxException): + """The experimental control-mode runner failed.""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class ControlModeBlock: + """One ``%begin``/``%end`` or ``%error`` control-mode command block.""" + + number: int + flags: int + is_error: bool + body: tuple[bytes, ...] + + +@dataclasses.dataclass(slots=True) +class _PendingBlock: + number: int + flags: int + body: list[bytes] + + +class ControlModeParser: + """I/O-free parser for the subset of control mode needed by chains.""" + + __slots__ = ("_blocks", "_buffer", "_pending") + + def __init__(self) -> None: + self._buffer = bytearray() + self._blocks: list[ControlModeBlock] = [] + self._pending: _PendingBlock | None = None + + def feed(self, data: bytes) -> None: + """Consume bytes from tmux stdout.""" + if not data: + return + self._buffer.extend(data) + while True: + newline = self._buffer.find(b"\n") + if newline < 0: + return + line = bytes(self._buffer[:newline]) + del self._buffer[: newline + 1] + self._handle_line(line) + + def blocks(self) -> list[ControlModeBlock]: + """Drain parsed command blocks.""" + blocks, self._blocks = self._blocks, [] + return blocks + + def _handle_line(self, line: bytes) -> None: + if self._pending is not None: + if _matches_pending_close(line, self._pending.number): + self._close_block(line) + return + self._pending.body.append(line) + return + + if line.startswith(_BEGIN_PREFIX): + self._open_block(line) + + def _open_block(self, line: bytes) -> None: + number, flags = _parse_guard(line, _BEGIN_PREFIX) + if number is None: + return + self._pending = _PendingBlock(number=number, flags=flags or 0, body=[]) + + def _close_block(self, line: bytes) -> None: + pending = self._pending + self._pending = None + if pending is None: + return + + prefix = _ERROR_PREFIX if line.startswith(_ERROR_PREFIX) else _END_PREFIX + number, _flags = _parse_guard(line, prefix) + if number is not None and number != pending.number: + logger.warning( + "control-mode close guard number mismatch", + extra={ + "tmux_cm_block_id": pending.number, + "tmux_cm_close_id": number, + }, + ) + + self._blocks.append( + ControlModeBlock( + number=pending.number, + flags=pending.flags, + is_error=line.startswith(_ERROR_PREFIX), + body=tuple(pending.body), + ), + ) + + +@dataclasses.dataclass(slots=True) +class ControlModeResult: + """Result shape returned by :class:`ControlModeRunner`.""" + + stdout: list[str] + stderr: list[str] + returncode: int + + +class ControlModeRunner: + """Persistent ``tmux -C`` runner for experimental command chains. + + The runner batches independent command lines over one control-mode + connection. Unlike a native ``;`` chain, each line returns its own + ``%begin``/``%end`` block, so callers can keep per-command stdout and + return codes while avoiding per-command subprocess startup. + """ + + def __init__( + self, + server: Server, + *, + timeout: float = _DEFAULT_TIMEOUT, + ) -> None: + self.server = server + self.timeout = timeout + self._lock = threading.Lock() + self._parser = ControlModeParser() + self._proc: subprocess.Popen[bytes] | None = None + self._selector: selectors.DefaultSelector | None = None + self._startup_ack_pending = True + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> ControlModeResult: + """Dispatch one command through the persistent control client.""" + call = CommandCall(name=cmd, args=tuple(args), target=target) + return self.run_calls([call])[0] + + def run_calls(self, calls: Sequence[CommandCall]) -> list[ControlModeResult]: + """Dispatch command calls as one control-mode batch.""" + return self.run_argvs([call.argv() for call in calls]) + + def run_chain(self, chain: CommandChain) -> list[ControlModeResult]: + """Dispatch a ``CommandChain`` with one result per contained call.""" + return self.run_calls(chain.calls) + + def run_argvs(self, argvs: Sequence[Sequence[Arg]]) -> list[ControlModeResult]: + """Dispatch rendered tmux argv rows as one control-mode batch.""" + if not argvs: + return [] + + rendered = [tuple(str(arg) for arg in argv) for argv in argvs] + with self._lock: + self._ensure_started() + payload = b"".join( + (shlex.join(argv) + "\n").encode("utf-8") for argv in rendered + ) + self._write(payload) + blocks = self._read_blocks(len(rendered)) + + return [_result_from_block(block) for block in blocks] + + def close(self) -> None: + """Close the control-mode subprocess. + + Acquires the run lock so teardown never closes the selector out from + under an in-flight :meth:`run_argvs` reading on another thread. + """ + with self._lock: + proc = self._proc + selector = self._selector + self._proc = None + self._selector = None + self._parser = ControlModeParser() + self._startup_ack_pending = True + + if selector is not None: + with contextlib.suppress(Exception): + selector.close() + if proc is None: + return + + if proc.stdin is not None and not proc.stdin.closed: + with contextlib.suppress(OSError): + proc.stdin.write(b"\n") + proc.stdin.flush() + if not _wait_for_exit(proc, _GRACEFUL_EXIT_TIMEOUT): + if proc.stdin is not None and not proc.stdin.closed: + with contextlib.suppress(OSError): + proc.stdin.close() + if not _wait_for_exit(proc, _GRACEFUL_EXIT_TIMEOUT): + with contextlib.suppress(OSError): + proc.terminate() + if not _wait_for_exit(proc, _TERMINATE_TIMEOUT): + with contextlib.suppress(OSError): + proc.kill() + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=_TERMINATE_TIMEOUT) + + def __enter__(self) -> ControlModeRunner: + """Return this runner.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Close the control-mode subprocess.""" + self.close() + + def _ensure_started(self) -> None: + if self._proc is not None: + if self._proc.poll() is not None: + msg = f"tmux -C exited with code {self._proc.returncode}" + raise ControlModeError(msg) + return + + tmux_bin = self.server.tmux_bin or shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + + cmd = [tmux_bin, *self._server_args(), "-C"] + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + + if proc.stdin is None or proc.stdout is None or proc.stderr is None: + with contextlib.suppress(OSError): + proc.kill() + msg = "tmux -C subprocess pipes are unavailable" + raise ControlModeError(msg) + + os.set_blocking(proc.stdout.fileno(), False) + os.set_blocking(proc.stderr.fileno(), False) + selector = selectors.DefaultSelector() + selector.register(proc.stdout, selectors.EVENT_READ, "stdout") + selector.register(proc.stderr, selectors.EVENT_READ, "stderr") + self._proc = proc + self._selector = selector + self._startup_ack_pending = True + + def _server_args(self) -> list[str]: + args: list[str] = [] + if self.server.socket_name: + args.extend(("-L", str(self.server.socket_name))) + if self.server.socket_path: + args.extend(("-S", str(self.server.socket_path))) + if self.server.config_file: + args.extend(("-f", str(self.server.config_file))) + if self.server.colors == 256: + args.append("-2") + elif self.server.colors == 88: + args.append("-8") + elif self.server.colors is not None: + raise exc.UnknownColorOption + return args + + def _write(self, payload: bytes) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + try: + proc.stdin.write(payload) + proc.stdin.flush() + except (BrokenPipeError, OSError) as error: + msg = f"tmux control-mode write failed: {error}" + raise ControlModeError(msg) from error + + def _read_blocks(self, count: int) -> list[ControlModeBlock]: + proc = self._proc + selector = self._selector + if proc is None or selector is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + + blocks: list[ControlModeBlock] = [] + deadline = time.monotonic() + self.timeout + while len(blocks) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + msg = ( + "tmux control-mode timed out after " + f"{self.timeout}s waiting for {count} result blocks" + ) + raise ControlModeError(msg) + + ready = selector.select(min(remaining, 0.1)) + if not ready: + if proc.poll() is not None: + msg = f"tmux -C exited with code {proc.returncode}" + raise ControlModeError(msg) + continue + + for key, _events in ready: + if key.data == "stdout": + self._read_stdout() + elif key.data == "stderr": + self._read_stderr() + + for block in self._parser.blocks(): + if self._startup_ack_pending: + self._startup_ack_pending = False + if block.flags == 0: + continue + blocks.append(block) + if len(blocks) == count: + break + + return blocks + + def _read_stdout(self) -> None: + proc = self._proc + assert proc is not None + assert proc.stdout is not None + try: + chunk = os.read(proc.stdout.fileno(), _READ_CHUNK) + except BlockingIOError: + return + except OSError as error: + msg = f"tmux control-mode stdout read failed: {error}" + raise ControlModeError(msg) from error + if not chunk: + msg = "tmux -C closed stdout" + raise ControlModeError(msg) + self._parser.feed(chunk) + + def _read_stderr(self) -> None: + proc = self._proc + assert proc is not None + assert proc.stderr is not None + try: + chunk = os.read(proc.stderr.fileno(), _READ_CHUNK) + except (BlockingIOError, OSError): + return + if chunk: + logger.debug( + "tmux control-mode stderr", + extra={"tmux_stderr": [chunk.decode("utf-8", errors="replace")]}, + ) + + +def _parse_guard( + line: bytes, + prefix: bytes, +) -> tuple[int | None, int | None]: + rest = line[len(prefix) :] + parts = rest.split() + if len(parts) < 3: + return (None, None) + try: + number = int(parts[1]) + flags = int(parts[2]) + except ValueError: + return (None, None) + return (number, flags) + + +def _matches_pending_close(line: bytes, pending_number: int) -> bool: + if line.startswith(_END_PREFIX): + number, _flags = _parse_guard(line, _END_PREFIX) + return number == pending_number + if line.startswith(_ERROR_PREFIX): + number, _flags = _parse_guard(line, _ERROR_PREFIX) + return number == pending_number + return False + + +def _result_from_block(block: ControlModeBlock) -> ControlModeResult: + lines = [line.decode("utf-8", errors="replace") for line in block.body] + if block.is_error: + return ControlModeResult(stdout=[], stderr=_trim_lines(lines), returncode=1) + return ControlModeResult(stdout=_trim_lines(lines), stderr=[], returncode=0) + + +def _trim_lines(lines: list[str]) -> list[str]: + trimmed = list(lines) + while trimmed and not trimmed[-1].strip(): + trimmed.pop() + return trimmed + + +def _wait_for_exit(proc: subprocess.Popen[bytes], timeout: float) -> bool: + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + return False + return True + + +__all__ = [ + "ControlModeBlock", + "ControlModeError", + "ControlModeParser", + "ControlModeResult", + "ControlModeRunner", +] diff --git a/src/libtmux/_experimental/chain/ir.py b/src/libtmux/_experimental/chain/ir.py new file mode 100644 index 000000000..8bf9d4f0f --- /dev/null +++ b/src/libtmux/_experimental/chain/ir.py @@ -0,0 +1,382 @@ +r"""Immutable argv intermediate representation for tmux command sequences. + +This module is the substrate for the chainable-commands API. A +:class:`CommandCall` is one typed tmux command before dispatch; a +:class:`CommandChain` is an ordered group of calls that renders to a single +native ``tmux ... \\; ...`` invocation and dispatches once through a +:class:`CommandRunner` (which a live :class:`libtmux.Server` already satisfies). + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import logging +import typing as t +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +Arg: t.TypeAlias = "str | int" +"""A single tmux argument token. Integers are rendered with :func:`str`.""" + +CommandScope: t.TypeAlias = t.Literal["server", "session", "window", "pane"] +"""The tmux object scope a command targets.""" + + +class CommandResultLike(t.Protocol): + """Result protocol matching the libtmux command-result surface. + + A live :class:`libtmux.common.tmux_cmd` satisfies this protocol, as does + any object exposing ``stdout``/``stderr`` line lists and a ``returncode``. + """ + + stdout: list[str] + stderr: list[str] + returncode: int + + +class CommandRunner(t.Protocol): + """Object capable of dispatching one tmux command argv. + + A live :class:`libtmux.Server` already matches this protocol via its + ``cmd()`` method, so sequences can be dispatched without an adapter for the + common case. Object-level ``cmd()`` wrappers such as ``Session.cmd()`` add + their own target context; use ``session.server`` or + :class:`~libtmux._experimental.chain._connection.SessionPlanExecutor` for + composed command sequences. + """ + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch a single tmux command and return its result.""" + ... + + +@dataclass(frozen=True, slots=True) +class CommandSpec: + """Static metadata describing a tmux command. + + Parameters + ---------- + name : str + The tmux command name, e.g. ``"rename-window"``. + scope : CommandScope + The tmux object scope the command targets. + chainable : bool + Whether the command may be folded into a one-dispatch sequence. A + command is chainable only when its output is not consumed mid-chain; + commands that must return output immediately (e.g. ``show-option``) set + this to ``False``. + + Examples + -------- + >>> CommandSpec(name="rename-window", scope="window") + CommandSpec(name='rename-window', scope='window', chainable=True) + >>> CommandSpec(name="show-option", scope="server", chainable=False).chainable + False + """ + + name: str + scope: CommandScope + chainable: bool = True + + +@dataclass(frozen=True, slots=True) +class SlotRef: + r"""An unresolved target: "the id of forward slot N", filled at resolve time. + + Carried by a :class:`CommandCall` built against a forward handle in a + multi-dispatch plan. The resolver replaces it with the captured concrete id + (``%N``/``@N``/``$N``) plus ``suffix`` before the call is rendered; rendering + an unresolved SlotRef is a planner bug and raises. ``suffix`` lets a command + that needs a qualified target -- e.g. ``new-window -t $N:`` -- reuse a plain + captured ``$N``. + + Examples + -------- + >>> SlotRef(0) + SlotRef(slot=0, suffix='') + >>> SlotRef(0, ":") + SlotRef(slot=0, suffix=':') + """ + + slot: int + suffix: str = "" + + +@dataclass(frozen=True, slots=True) +class CommandCall: + """One typed tmux command call before subprocess dispatch. + + Parameters + ---------- + name : str + The tmux command name. + args : tuple[Arg, ...] + Positional argument tokens, rendered in order after the target. + target : str | int | None + Optional ``-t`` target inserted immediately after the command name. + + Examples + -------- + >>> CommandCall("new-window", ("-d", "-n", "work")).argv() + ('new-window', '-d', '-n', 'work') + + A target is rendered as a ``-t`` flag right after the command name: + + >>> CommandCall("split-window", ("-h",), target="%1").argv() + ('split-window', '-t', '%1', '-h') + """ + + name: str + args: tuple[Arg, ...] = () + target: str | int | None | SlotRef = None + + def __post_init__(self) -> None: + """Reject an empty-string target (fail closed). + + An empty ``-t ''`` resolves to tmux's *current/attached* target, which + silently defeats the typed-target guarantee. ``None`` means "no target" + and is allowed; an integer target such as ``0`` is allowed. + + Examples + -------- + >>> CommandCall("kill-window", target="") + Traceback (most recent call last): + ... + ValueError: CommandCall target must be a non-empty string or None + >>> CommandCall("select-window", target=0).argv() + ('select-window', '-t', '0') + """ + if self.target == "": + msg = "CommandCall target must be a non-empty string or None" + raise ValueError(msg) + + def argv(self) -> tuple[str, ...]: + """Render this call as tmux argv tokens. + + Returns + ------- + tuple[str, ...] + The command name, optional ``-t ``, then each argument. + + Examples + -------- + >>> CommandCall("kill-window", target="@1").argv() + ('kill-window', '-t', '@1') + """ + rendered: list[str] = [self.name] + if isinstance(self.target, SlotRef): + msg = "cannot render an unresolved SlotRef; resolve the plan first" + raise TypeError(msg) + if self.target is not None: + rendered.extend(("-t", str(self.target))) + rendered.extend(_render_arg(arg) for arg in self.args) + return tuple(rendered) + + def then(self, other: CommandCall | CommandChain) -> CommandChain: + """Return a sequence with ``other`` appended after this call. + + Parameters + ---------- + other : CommandCall | CommandChain + The call or sequence to append. + + Returns + ------- + CommandChain + + Examples + -------- + >>> seq = CommandCall("new-window").then(CommandCall("split-window")) + >>> seq.argvs() + (('new-window',), ('split-window',)) + """ + if isinstance(other, CommandCall): + return CommandChain((self, other)) + return CommandChain((self, *other.calls)) + + def __rshift__(self, other: CommandCall | CommandChain) -> CommandChain: + """Compose command calls with ``>>``. + + Examples + -------- + >>> (CommandCall("new-window") >> CommandCall("split-window")).argv() + ('new-window', ';', 'split-window') + """ + return self.then(other) + + +@dataclass(frozen=True, slots=True) +class CommandChain: + r"""An ordered tmux command sequence dispatched as one invocation. + + A sequence renders to a single argv list using standalone ``;`` separator + tokens, mirroring tmux's native command-sequence syntax + (``tmux cmd-a \\; cmd-b``). Later commands do not run if an earlier command + in the sequence errors -- the same semantics tmux applies to ``;`` chains. + + Parameters + ---------- + calls : tuple[CommandCall, ...] + The ordered, non-empty calls in the sequence. + + Examples + -------- + >>> seq = CommandCall("new-window", ("-d",)) >> CommandCall("split-window", ("-h",)) + >>> seq.argv() + ('new-window', '-d', ';', 'split-window', '-h') + """ + + calls: tuple[CommandCall, ...] + + def __post_init__(self) -> None: + """Reject empty sequences. + + Examples + -------- + >>> CommandChain(()) + Traceback (most recent call last): + ... + ValueError: CommandChain requires at least one call + """ + if not self.calls: + msg = "CommandChain requires at least one call" + raise ValueError(msg) + + def argv(self) -> tuple[str, ...]: + """Render the full sequence with tmux ``;`` separators. + + Returns + ------- + tuple[str, ...] + One flat argv list, with a standalone ``";"`` token between calls. + + Examples + -------- + >>> seq = CommandCall("rename-window", ("work",)) >> CommandCall("split-window") + >>> seq.argv() + ('rename-window', 'work', ';', 'split-window') + """ + rendered: list[str] = [] + for index, call in enumerate(self.calls): + if index: + rendered.append(";") + rendered.extend(call.argv()) + return tuple(rendered) + + def argvs(self) -> tuple[tuple[str, ...], ...]: + """Render each call independently, without separators. + + Returns + ------- + tuple[tuple[str, ...], ...] + One argv tuple per call. Useful for asserting the compiled commands + in tests without reasoning about ``;`` placement. + + Examples + -------- + >>> seq = CommandCall("rename-window", ("work",)) >> CommandCall("split-window") + >>> seq.argvs() + (('rename-window', 'work'), ('split-window',)) + """ + return tuple(call.argv() for call in self.calls) + + def then(self, other: CommandCall | CommandChain) -> CommandChain: + """Return a sequence with ``other`` appended. + + Parameters + ---------- + other : CommandCall | CommandChain + + Returns + ------- + CommandChain + + Examples + -------- + >>> base = CommandChain((CommandCall("new-window"),)) + >>> base.then(CommandCall("split-window")).argvs() + (('new-window',), ('split-window',)) + """ + if isinstance(other, CommandCall): + return CommandChain((*self.calls, other)) + return CommandChain((*self.calls, *other.calls)) + + def __rshift__(self, other: CommandCall | CommandChain) -> CommandChain: + """Compose sequences with ``>>``. + + Examples + -------- + >>> seq = CommandChain((CommandCall("new-window"),)) + >>> (seq >> CommandCall("kill-window")).argvs() + (('new-window',), ('kill-window',)) + """ + return self.then(other) + + def run(self, runner: CommandRunner) -> CommandResultLike: + """Dispatch the whole sequence through one runner call. + + Parameters + ---------- + runner : CommandRunner + Any object with a ``Server.cmd``-shaped ``cmd()`` method. A live + :class:`libtmux.Server` works directly. + + Returns + ------- + CommandResultLike + The single result of the one-shot dispatch. + + Examples + -------- + Two server options set in a single tmux invocation: + + >>> seq = ( + ... CommandCall("set-option", ("-g", "@cc_demo_a", "1")) + ... >> CommandCall("set-option", ("-g", "@cc_demo_b", "2")) + ... ) + >>> result = seq.run(session.server) + >>> result.returncode + 0 + >>> session.server.cmd("show-option", "-gv", "@cc_demo_b").stdout + ['2'] + """ + argv = self.argv() + logger.debug( + "tmux command sequence dispatched", + extra={"tmux_cmd": " ".join(argv), "tmux_subcommand": argv[0]}, + ) + result = runner.cmd(argv[0], *argv[1:]) + logger.debug( + "tmux command sequence complete", + extra={"tmux_exit_code": result.returncode}, + ) + return result + + +def _render_arg(arg: Arg) -> str: + r"""Render one argument token, escaping a trailing tmux separator. + + A literal argument that ends in ``;`` is escaped to ``\;`` so tmux does not + mistake it for a command separator inside a sequence. + + Examples + -------- + >>> _render_arg(50) + '50' + >>> _render_arg("echo hi;") + 'echo hi\\;' + """ + text = str(arg) + if text.endswith(";"): + return f"{text[:-1]}\\;" + return text diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py new file mode 100644 index 000000000..4ebe229cf --- /dev/null +++ b/src/libtmux/_experimental/chain/plan.py @@ -0,0 +1,1294 @@ +"""Typed, target-safe deferred query-command plans. + +A plan starts from a lazy :class:`PaneQuery`, resolves it against a pure +:class:`TmuxSnapshot`, maps each typed :class:`PaneRef` row to one or more +commands, and compiles the result into a single +:class:`~libtmux._experimental.chain.ir.CommandChain` -- which +dispatches once. Targets are typed (:class:`PaneTarget`, :class:`WindowTarget`, +:class:`SessionTarget`), so a row-bound command namespace cannot mis-target a +command. + +Compilation (:meth:`CommandPlan.to_chain`) is a pure function of the +snapshot, so a plan can be inspected in memory -- no tmux required -- and only +:meth:`CommandPlan.run` and :meth:`CommandPlan.run_deferred` touch a live +server. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses +import typing as t +from dataclasses import dataclass + +from libtmux._experimental.chain.chain import ( + DeferredCommandResult, + ensure_chainable, + validate_command_scope, +) +from libtmux._experimental.chain.ir import ( + Arg, + CommandCall, + CommandChain, + CommandResultLike, + CommandRunner, + SlotRef, +) + +if t.TYPE_CHECKING: + from typing_extensions import Self + +OrderField: t.TypeAlias = t.Literal["pane_id", "pane_index", "title"] +"""A :class:`PaneRef` field a query may order by.""" + +MappedT = t.TypeVar("MappedT") + + +class NoCommandsResolved(RuntimeError): + """Raised when a deferred plan resolves to no concrete commands.""" + + +@dataclass(frozen=True, slots=True) +class PaneTarget: + """A typed tmux pane target (e.g. ``%1``). + + Examples + -------- + >>> PaneTarget("%1") + PaneTarget(value='%1') + >>> str(PaneTarget("%1")) + '%1' + """ + + value: str + + @classmethod + def coerce(cls, target: str | PaneTarget) -> PaneTarget: + """Normalize raw pane-target text into a typed target. + + Examples + -------- + >>> PaneTarget.coerce("%2") + PaneTarget(value='%2') + >>> PaneTarget.coerce(PaneTarget("%2")) + PaneTarget(value='%2') + """ + if isinstance(target, str): + return cls(target) + return target + + def __str__(self) -> str: + """Render as the tmux target value.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class WindowTarget: + """A typed tmux window target (e.g. ``@1``). + + Examples + -------- + >>> str(WindowTarget("@1")) + '@1' + """ + + value: str + + @classmethod + def coerce(cls, target: str | WindowTarget) -> WindowTarget: + """Normalize raw window-target text into a typed target. + + Examples + -------- + >>> WindowTarget.coerce("@1") + WindowTarget(value='@1') + """ + if isinstance(target, str): + return cls(target) + return target + + def __str__(self) -> str: + """Render as the tmux target value.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class SessionTarget: + """A typed tmux session target (e.g. ``$0``). + + Examples + -------- + >>> str(SessionTarget("$0")) + '$0' + """ + + value: str + + @classmethod + def coerce(cls, target: str | SessionTarget) -> SessionTarget: + """Normalize raw session-target text into a typed target. + + Examples + -------- + >>> SessionTarget.coerce("$0") + SessionTarget(value='$0') + """ + if isinstance(target, str): + return cls(target) + return target + + def __str__(self) -> str: + """Render as the tmux target value.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class PendingTarget: + """A tmux runtime token for an object that does not exist yet. + + A *forward* ref -- the pane/window/session a creation verb will make -- has + no id until tmux runs, so it is addressed at dispatch via a runtime token: + ``"active"`` renders to ``None`` (no ``-t``), since a non-detached + split/new-window/new-session activates what it creates and the next commands + hit it; ``"marked"`` renders to ``"{marked}"`` (the ``select-pane -m`` pane). + + Examples + -------- + >>> PendingTarget().render() is None + True + >>> PendingTarget("marked").render() + '{marked}' + """ + + slot: t.Literal["active", "marked"] = "active" + + def render(self) -> str | None: + """Render as the tmux runtime token (``None`` for active, else marked).""" + return None if self.slot == "active" else "{marked}" + + @property + def value(self) -> str: + """Display form (``""`` for active) so a ``*TargetT`` union is uniform.""" + return self.render() or "" + + +PaneTargetT: t.TypeAlias = "PaneTarget | PendingTarget" +WindowTargetT: t.TypeAlias = "WindowTarget | PendingTarget" +SessionTargetT: t.TypeAlias = "SessionTarget | PendingTarget" +AnyTarget: t.TypeAlias = ( + "PaneTarget | WindowTarget | SessionTarget | PendingTarget | SlotRef" +) + + +def _target_arg(target: AnyTarget) -> str | int | None | SlotRef: + """Render a target as a concrete id or a pending token (the single seam). + + Examples + -------- + >>> _target_arg(PaneTarget("%1")) + '%1' + >>> _target_arg(PendingTarget()) is None + True + """ + if isinstance(target, PendingTarget): + return target.render() + if isinstance(target, SlotRef): + return target # deferred; the multi-dispatch resolver substitutes it + return target.value + + +class ForwardDataUnavailable(RuntimeError): + """Raised when forward-ref metadata is read before tmux creates the object.""" + + +def _forward_data(field: str) -> t.NoReturn: + """Raise: a forward ref has no metadata until tmux creates the object.""" + msg = f"{field} is unavailable on a forward ref until tmux creates the object" + raise ForwardDataUnavailable(msg) + + +class _ForwardRef: + """Shared forward-chaining surface for the typed refs. + + A ref accumulates a one-dispatch ``_lineage`` of creation/decoration calls; + this mixin compiles and dispatches it. Defining it once keeps + :class:`PaneRef`/:class:`WindowRef`/:class:`SessionRef` free of repetition. + """ + + __slots__ = () + + if t.TYPE_CHECKING: + _lineage: tuple[CommandCall, ...] + + def do(self, build: cabc.Callable[[Self], IntoCommands]) -> Self: + """Append commands built from this ref via its own namespaces. + + The fluent way to act on a forward ref with no new vocabulary: ``build`` + uses the existing ``.cmd``/``.window``/``.session`` namespaces; the + cursor (this ref) is unchanged. + + Examples + -------- + >>> ref = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> ref.do(lambda p: p.cmd.send_keys("vim", enter=True)).to_chain().argvs() + (('send-keys', '-t', '%1', 'vim', 'Enter'),) + """ + extra = _to_calls(build(self)) + return dataclasses.replace(self, _lineage=(*self._lineage, *extra)) # type: ignore[type-var] + + def to_chain(self) -> CommandChain: + """Fold the accumulated lineage into one chain (chainability-checked). + + Examples + -------- + >>> ref = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> ref.split().to_chain().argvs() + (('split-window', '-t', '%1', '-v'),) + """ + return _compile_lineage(self._lineage) + + def run(self, runner: CommandRunner) -> CommandResultLike: + """Dispatch the accumulated lineage in one tmux invocation. + + Examples + -------- + Dispatch the lineage against a live server in one invocation: + + >>> ref = PaneRef.concrete( + ... pane_id=pane.pane_id, window_id=pane.window_id, + ... session_id=pane.session_id, pane_index=0, active=True, title="", + ... ) + >>> built = ref.do(lambda p: p.cmd.send_keys("echo hi", enter=True)) + >>> built.run(pane.server).returncode + 0 + """ + return self.to_chain().run(runner) + + +def _compile_lineage(calls: tuple[CommandCall, ...]) -> CommandChain: + """Chainability-check an accumulated lineage and fold it into one chain.""" + for call in calls: + ensure_chainable(call.name) + return CommandChain(calls) + + +class CommandValue: + """Base for typed command values produced by a deferred plan. + + Subclasses carry their own typed target and compile to an + :class:`~libtmux._experimental.chain.ir.CommandCall`. + """ + + def to_call(self) -> CommandCall: + """Compile this command value into a shared command call.""" + raise NotImplementedError + + def argv(self) -> tuple[str, ...]: + """Render this command value as tmux argv tokens. + + Examples + -------- + >>> SendKeys(PaneTarget("%1"), "clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + return self.to_call().argv() + + +CommandLike: t.TypeAlias = "CommandValue | CommandCall" +IntoCommands: t.TypeAlias = "CommandLike | cabc.Iterable[CommandLike]" + + +@dataclass(frozen=True, slots=True) +class SendKeys(CommandValue): + """A typed ``send-keys`` command bound to a pane. + + Examples + -------- + >>> SendKeys(PaneTarget("%1"), "clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + + target: PaneTargetT | SlotRef + command: str + enter: bool = False + + def to_call(self) -> CommandCall: + """Compile to a shared command call. + + Examples + -------- + >>> SendKeys(PaneTarget("%1"), "clear", enter=True).to_call().argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + args: list[Arg] = [self.command] + if self.enter: + args.append("Enter") + return CommandCall("send-keys", tuple(args), target=_target_arg(self.target)) + + +@dataclass(frozen=True, slots=True) +class ResizePane(CommandValue): + """A typed ``resize-pane`` command bound to a pane. + + Examples + -------- + >>> ResizePane(PaneTarget("%1"), height=20).argv() + ('resize-pane', '-t', '%1', '-y', '20') + """ + + target: PaneTargetT | SlotRef + height: int + + def to_call(self) -> CommandCall: + """Compile to a shared command call. + + Examples + -------- + >>> ResizePane(PaneTarget("%1"), height=20).to_call().argv() + ('resize-pane', '-t', '%1', '-y', '20') + """ + return CommandCall( + "resize-pane", + ("-y", self.height), + target=_target_arg(self.target), + ) + + +@dataclass(frozen=True, slots=True) +class SelectLayout(CommandValue): + """A typed ``select-layout`` command bound to a window. + + Examples + -------- + >>> SelectLayout(WindowTarget("@1"), "even-horizontal").argv() + ('select-layout', '-t', '@1', 'even-horizontal') + """ + + target: WindowTargetT | SlotRef + layout: str + + def to_call(self) -> CommandCall: + """Compile to a shared command call. + + Examples + -------- + >>> SelectLayout(WindowTarget("@1"), "tiled").to_call().argv() + ('select-layout', '-t', '@1', 'tiled') + """ + return CommandCall( + "select-layout", (self.layout,), target=_target_arg(self.target) + ) + + +class BoundPaneCommands: + """Pane command namespace bound to one typed pane target. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).send_keys("clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + + def __init__(self, target: PaneTargetT | SlotRef) -> None: + self.target = target + + def send_keys(self, command: str, *, enter: bool = False) -> SendKeys: + """Build a target-bound ``send-keys`` command. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).send_keys("clear").argv() + ('send-keys', '-t', '%1', 'clear') + """ + return SendKeys(target=self.target, command=command, enter=enter) + + def resize_pane(self, *, height: int) -> ResizePane: + """Build a target-bound ``resize-pane`` command. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).resize_pane(height=20).argv() + ('resize-pane', '-t', '%1', '-y', '20') + """ + return ResizePane(target=self.target, height=height) + + def set_option(self, name: str, value: Arg) -> CommandCall: + """Build a pane-scoped ``set-option -p`` bound to this pane. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).set_option("@x", "1").argv() + ('set-option', '-t', '%1', '-p', '@x', '1') + """ + return self.raw("set-option", "-p", name, value) + + def select(self) -> CommandCall: + """Build a ``select-pane`` bound to this pane. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).select().argv() + ('select-pane', '-t', '%1') + """ + return self.raw("select-pane") + + def raw(self, name: str, *args: Arg) -> CommandCall: + """Build an arbitrary pane-scoped command bound to this pane. + + The typed escape hatch: any tmux command, with the pane target + pre-bound, for commands without a first-class builder. Still subject to + the chainability check when compiled in a plan. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).raw("pipe-pane", "-o").argv() + ('pipe-pane', '-t', '%1', '-o') + """ + validate_command_scope(name, "pane") + return CommandCall(name, args, target=_target_arg(self.target)) + + +class BoundWindowCommands: + """Window command namespace bound to one typed window target. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).select_layout("tiled").argv() + ('select-layout', '-t', '@1', 'tiled') + """ + + def __init__(self, target: WindowTargetT | SlotRef) -> None: + self.target = target + + def select_layout(self, layout: str) -> SelectLayout: + """Build a target-bound ``select-layout`` command. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).select_layout("tiled").argv() + ('select-layout', '-t', '@1', 'tiled') + """ + return SelectLayout(target=self.target, layout=layout) + + def set_option(self, name: str, value: Arg) -> CommandCall: + """Build a window-scoped ``set-option -w`` bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).set_option("mode-keys", "vi").argv() + ('set-option', '-t', '@1', '-w', 'mode-keys', 'vi') + """ + return self.raw("set-option", "-w", name, value) + + def rename(self, name: str) -> CommandCall: + """Build a ``rename-window`` bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).rename("editor").argv() + ('rename-window', '-t', '@1', 'editor') + """ + return self.raw("rename-window", name) + + def select(self) -> CommandCall: + """Build a ``select-window`` bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).select().argv() + ('select-window', '-t', '@1') + """ + return self.raw("select-window") + + def raw(self, name: str, *args: Arg) -> CommandCall: + """Build an arbitrary window-scoped command bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).raw("set-option", "@x", "1").argv() + ('set-option', '-t', '@1', '@x', '1') + """ + validate_command_scope(name, "window") + return CommandCall(name, args, target=_target_arg(self.target)) + + +class BoundSessionCommands: + """Session command namespace bound to one typed session target. + + The session scope exists mainly for the ``raw`` escape hatch -- e.g. the + per-session ``set-option`` loops that workspace builders issue. + + Examples + -------- + >>> BoundSessionCommands(SessionTarget("$0")).raw("set-option", "@x", "1").argv() + ('set-option', '-t', '$0', '@x', '1') + """ + + def __init__(self, target: SessionTargetT | SlotRef) -> None: + self.target = target + + def set_option(self, name: str, value: Arg) -> CommandCall: + """Build a session-scoped ``set-option`` bound to this session. + + Examples + -------- + >>> BoundSessionCommands(SessionTarget("$0")).set_option("status", "on").argv() + ('set-option', '-t', '$0', 'status', 'on') + """ + return self.raw("set-option", name, value) + + def set_environment(self, name: str, value: str) -> CommandCall: + """Build a session-scoped ``set-environment`` bound to this session. + + Examples + -------- + >>> cmds = BoundSessionCommands(SessionTarget("$0")) + >>> cmds.set_environment("EDITOR", "vim").argv() + ('set-environment', '-t', '$0', 'EDITOR', 'vim') + """ + return self.raw("set-environment", name, value) + + def rename(self, name: str) -> CommandCall: + """Build a ``rename-session`` bound to this session. + + Examples + -------- + >>> BoundSessionCommands(SessionTarget("$0")).rename("work").argv() + ('rename-session', '-t', '$0', 'work') + """ + return self.raw("rename-session", name) + + def raw(self, name: str, *args: Arg) -> CommandCall: + """Build an arbitrary session-scoped command bound to this session.""" + validate_command_scope(name, "session") + return CommandCall(name, args, target=_target_arg(self.target)) + + +@dataclass(frozen=True, slots=True) +class PaneRef(_ForwardRef): + r"""A typed pane handle -- concrete (a snapshot row) or forward, one type. + + A *concrete* ref comes from a query/snapshot: a real ``%id`` and metadata + (``pane_index``/``active``/``title``). A *forward* ref is declared before the + pane exists (``pane.split()``); its id is a :class:`PendingTarget` resolved + at dispatch and reading its metadata raises until tmux creates it. The + ``.cmd``/``.window``/``.session`` namespaces work identically on both. + + Examples + -------- + Concrete (from a snapshot): build a command bound to this pane's real ids. + + >>> pane = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> pane.cmd.send_keys("clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + >>> pane.title + 'editor' + >>> pane.is_forward + False + + Forward: split it, then split the pane that split just created -- one chain. + + >>> pane.split(horizontal=True).split().to_chain().argvs() + (('split-window', '-t', '%1', '-h'), ('split-window', '-v')) + """ + + pane_id: PaneTargetT + window_id: WindowTargetT + session_id: SessionTargetT + _pane_index: int | None = None + _active: bool | None = None + _title: str | None = None + _lineage: tuple[CommandCall, ...] = () + + @classmethod + def concrete( + cls, + *, + pane_id: str | PaneTarget, + window_id: str | WindowTarget, + session_id: str | SessionTarget, + pane_index: int, + active: bool, + title: str, + ) -> PaneRef: + """Build a concrete pane row (real ids and metadata). + + Examples + -------- + >>> ref = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> (ref.is_forward, ref.title) + (False, 'editor') + """ + return cls( + pane_id=PaneTarget.coerce(pane_id), + window_id=WindowTarget.coerce(window_id), + session_id=SessionTarget.coerce(session_id), + _pane_index=pane_index, + _active=active, + _title=title, + ) + + @property + def is_forward(self) -> bool: + """Whether this ref's id resolves at dispatch (vs. a known ``%id``).""" + return isinstance(self.pane_id, PendingTarget) + + @property + def pane_index(self) -> int: + """Pane index (raises on a forward ref -- the pane does not exist yet).""" + if self._pane_index is None: + _forward_data("pane_index") + return self._pane_index + + @property + def active(self) -> bool: + """Whether the pane is active (raises on a forward ref).""" + if self._active is None: + _forward_data("active") + return self._active + + @property + def title(self) -> str: + """Pane title (raises on a forward ref).""" + if self._title is None: + _forward_data("title") + return self._title + + @property + def cmd(self) -> BoundPaneCommands: + """Pane-scoped commands bound to this pane (concrete id or pending token).""" + return BoundPaneCommands(self.pane_id) + + @property + def window(self) -> BoundWindowCommands: + """Window-scoped commands bound to this pane's window.""" + return BoundWindowCommands(self.window_id) + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this pane's session.""" + return BoundSessionCommands(self.session_id) + + def split(self, *, horizontal: bool = False, shell: str | None = None) -> PaneRef: + r"""Split this pane; return a FORWARD ref to the new (active) pane. + + The new pane stays in this pane's window/session; its own id is pending + until dispatch -- a non-detached split activates it, so later commands + hit it with no ``-t``. + """ + args: list[Arg] = ["-h" if horizontal else "-v"] + if shell is not None: + args.append(shell) + call = CommandCall( + "split-window", tuple(args), target=_target_arg(self.pane_id) + ) + return PaneRef( + pane_id=PendingTarget("active"), + window_id=self.window_id, + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + def break_pane(self, *, name: str | None = None) -> WindowRef: + r"""Break this pane into a new window; return a FORWARD :class:`WindowRef`.""" + args: list[Arg] = ["-s", _require_id(self.pane_id)] + args += ["-t", f"{_require_id(self.session_id)}:"] # scope to owning session + if name is not None: + args += ["-n", name] + call = CommandCall("break-pane", tuple(args)) + return WindowRef( + window_id=PendingTarget("active"), + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + +CommandMapper: t.TypeAlias = cabc.Callable[[PaneRef], IntoCommands] + + +def _require_id(target: AnyTarget) -> str: + """Return a concrete id, or raise -- creation verbs need a real source id.""" + if isinstance(target, (PendingTarget, SlotRef)): + msg = "a creation verb needs a concrete source id, not a forward ref" + raise ForwardDataUnavailable(msg) + return target.value + + +@dataclass(frozen=True, slots=True) +class WindowRef(_ForwardRef): + r"""A typed window handle -- concrete or forward, mirroring :class:`PaneRef`. + + Created by ``session.new_window()`` or ``pane.break_pane()``. Reuses the + ``.window``/``.session`` namespaces; ``.split()`` descends into a forward + :class:`PaneRef`. + + Examples + -------- + >>> win = WindowRef.concrete( + ... window_id="@1", session_id="$0", window_index=1, window_name="editor" + ... ) + >>> win.window.select_layout("tiled").argv() + ('select-layout', '-t', '@1', 'tiled') + >>> win.split().is_forward + True + """ + + window_id: WindowTargetT + session_id: SessionTargetT + _window_index: int | None = None + _window_name: str | None = None + _lineage: tuple[CommandCall, ...] = () + + @classmethod + def concrete( + cls, + *, + window_id: str | WindowTarget, + session_id: str | SessionTarget, + window_index: int, + window_name: str, + ) -> WindowRef: + """Build a concrete window row (real ids and metadata). + + Examples + -------- + >>> ref = WindowRef.concrete( + ... window_id="@1", session_id="$0", window_index=1, window_name="editor" + ... ) + >>> (ref.is_forward, ref.window_name) + (False, 'editor') + """ + return cls( + window_id=WindowTarget.coerce(window_id), + session_id=SessionTarget.coerce(session_id), + _window_index=window_index, + _window_name=window_name, + ) + + @property + def is_forward(self) -> bool: + """Whether this window resolves at dispatch.""" + return isinstance(self.window_id, PendingTarget) + + @property + def window_index(self) -> int: + """Window index (raises on a forward ref).""" + if self._window_index is None: + _forward_data("window_index") + return self._window_index + + @property + def window_name(self) -> str: + """Window name (raises on a forward ref).""" + if self._window_name is None: + _forward_data("window_name") + return self._window_name + + @property + def window(self) -> BoundWindowCommands: + """Window-scoped commands bound to this window.""" + return BoundWindowCommands(self.window_id) + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this window's session.""" + return BoundSessionCommands(self.session_id) + + def split(self, *, horizontal: bool = False, shell: str | None = None) -> PaneRef: + r"""Split this window's active pane; return a forward :class:`PaneRef`.""" + args: list[Arg] = ["-h" if horizontal else "-v"] + if shell is not None: + args.append(shell) + call = CommandCall( + "split-window", tuple(args), target=_target_arg(self.window_id) + ) + return PaneRef( + pane_id=PendingTarget("active"), + window_id=self.window_id, + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + +@dataclass(frozen=True, slots=True) +class SessionRef(_ForwardRef): + r"""A typed session handle -- concrete or forward, mirroring :class:`PaneRef`. + + Created by :func:`new_session`. Reuses the ``.session`` namespace; + ``.new_window()`` descends into a forward :class:`WindowRef`. + + Examples + -------- + >>> SessionRef.concrete(session_id="$0", session_name="ci").new_window( + ... name="build" + ... ).is_forward + True + """ + + session_id: SessionTargetT + _session_name: str | None = None + _lineage: tuple[CommandCall, ...] = () + + @classmethod + def concrete( + cls, *, session_id: str | SessionTarget, session_name: str + ) -> SessionRef: + """Build a concrete session row (real id and name). + + Examples + -------- + >>> ref = SessionRef.concrete(session_id="$0", session_name="ci") + >>> (ref.is_forward, ref.session_name) + (False, 'ci') + """ + return cls( + session_id=SessionTarget.coerce(session_id), + _session_name=session_name, + ) + + @property + def is_forward(self) -> bool: + """Whether this session resolves at dispatch.""" + return isinstance(self.session_id, PendingTarget) + + @property + def session_name(self) -> str: + """Session name (raises on a forward ref).""" + if self._session_name is None: + _forward_data("session_name") + return self._session_name + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this session.""" + return BoundSessionCommands(self.session_id) + + def new_window(self, *, name: str | None = None) -> WindowRef: + r"""Create a window in this session; return a forward :class:`WindowRef`.""" + args: list[Arg] = [] + target = _target_arg(self.session_id) + if target is not None: + args += ["-t", f"{target}:"] + if name is not None: + args += ["-n", name] + call = CommandCall("new-window", tuple(args)) + return WindowRef( + window_id=PendingTarget("active"), + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + +def new_session(*, name: str | None = None) -> SessionRef: + r"""Create a detached session; return a forward :class:`SessionRef`. + + Examples + -------- + >>> new_session(name="ci").new_window(name="build").to_chain().argvs() + (('new-session', '-d', '-s', 'ci'), ('new-window', '-n', 'build')) + """ + args: list[Arg] = ["-d"] + if name is not None: + args += ["-s", name] + call = CommandCall("new-session", tuple(args)) + return SessionRef(session_id=PendingTarget("active"), _lineage=(call,)) + + +@dataclass(frozen=True, slots=True) +class TmuxSnapshot: + """A pure snapshot of tmux pane state used to resolve plans. + + Examples + -------- + >>> snapshot = TmuxSnapshot(panes=()) + >>> snapshot.panes + () + """ + + panes: tuple[PaneRef, ...] + + +class SnapshotProvider(t.Protocol): + """Object that can provide a pure tmux snapshot.""" + + def snapshot(self) -> TmuxSnapshot: + """Return a tmux snapshot.""" + ... + + +class PlanRunner(CommandRunner, SnapshotProvider, t.Protocol): + """A runner that can both resolve snapshots and dispatch commands.""" + + +SnapshotSource: t.TypeAlias = "TmuxSnapshot | SnapshotProvider" + + +@dataclass(frozen=True, slots=True) +class PaneQuery: + """A lazy pane query that can become a deferred command plan. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=False, title="logs"), + ... ), + ... ) + >>> [p.pane_id.value for p in panes().filter(active=True).all(snapshot)] + ['%1'] + """ + + active_filter: bool | None = None + ordering: OrderField | None = None + limit_count: int | None = None + + def filter(self, *, active: bool) -> PaneQuery: + """Return a query filtered by active state. + + Examples + -------- + >>> panes().filter(active=True) + PaneQuery(active_filter=True, ordering=None, limit_count=None) + """ + return dataclasses.replace(self, active_filter=active) + + def order_by(self, field: OrderField) -> PaneQuery: + """Return a query ordered by a known pane field. + + Examples + -------- + >>> panes().order_by("pane_index") + PaneQuery(active_filter=None, ordering='pane_index', limit_count=None) + """ + return dataclasses.replace(self, ordering=field) + + def limit(self, count: int) -> PaneQuery: + """Return a query capped to ``count`` rows. + + Examples + -------- + >>> panes().limit(2) + PaneQuery(active_filter=None, ordering=None, limit_count=2) + """ + return dataclasses.replace(self, limit_count=count) + + def all(self, source: SnapshotSource) -> list[PaneRef]: + """Evaluate the query against a snapshot source. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=True, title="logs"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> [p.pane_id.value for p in panes().order_by("pane_index").all(snapshot)] + ['%1', '%2'] + """ + rows = list(_resolve_snapshot(source).panes) + if self.active_filter is not None: + rows = [row for row in rows if row.active == self.active_filter] + if self.ordering is not None: + ordering = self.ordering + rows.sort(key=lambda row: _order_value(row, ordering)) + if self.limit_count is not None: + rows = rows[: self.limit_count] + return rows + + def first(self, source: SnapshotSource) -> PaneRef | None: + """Evaluate the query and return its first row, or ``None``.""" + rows = self.limit(1).all(source) + if not rows: + return None + return rows[0] + + def map( + self, + mapper: cabc.Callable[[PaneRef], MappedT], + ) -> MappedPaneQuery[MappedT]: + """Return a data-only transformation query (no commands).""" + return MappedPaneQuery(query=self, mapper=mapper) + + def commands(self, mapper: CommandMapper) -> CommandPlan: + """Return a deferred plan where each row maps to one or more commands. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> plan = panes().commands( + ... lambda pane: ( + ... pane.cmd.resize_pane(height=10), + ... pane.window.select_layout("tiled"), + ... ), + ... ) + >>> compiled = plan.to_chain(snapshot).argvs() + >>> compiled[0] + ('resize-pane', '-t', '%1', '-y', '10') + >>> compiled[1] + ('select-layout', '-t', '@1', 'tiled') + """ + return CommandPlan(_CommandPlanNode(query=self, mapper=mapper)) + + +@dataclass(frozen=True, slots=True) +class MappedPaneQuery(t.Generic[MappedT]): + """A data-only query transformation over pane rows. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> panes().map(lambda pane: pane.title).all(snapshot) + ['editor'] + """ + + query: PaneQuery + mapper: cabc.Callable[[PaneRef], MappedT] + + def all(self, source: SnapshotSource) -> list[MappedT]: + """Evaluate the query and transform every row. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=True, title="logs"), + ... ), + ... ) + >>> panes().map(lambda pane: pane.title).all(snapshot) + ['editor', 'logs'] + """ + return [self.mapper(row) for row in self.query.all(source)] + + def first(self, source: SnapshotSource) -> MappedT | None: + """Evaluate the query and transform the first row, or ``None``. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> panes().map(lambda pane: pane.title).first(snapshot) + 'editor' + >>> panes().filter(active=False).map(lambda p: p.title).first(snapshot) is None + True + """ + row = self.query.first(source) + if row is None: + return None + return self.mapper(row) + + +@dataclass(frozen=True, slots=True) +class _CommandPlanNode: + """A deferred query plus a command mapper (an unresolved plan node).""" + + query: PaneQuery + mapper: CommandMapper + + +@dataclass(frozen=True, slots=True) +class CommandPlan: + """A lazy command plan that resolves a query into a command sequence. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=True, title="logs"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> plan = ( + ... panes() + ... .filter(active=True) + ... .order_by("pane_index") + ... .commands(lambda pane: pane.cmd.resize_pane(height=20)) + ... ) + >>> plan.to_chain(snapshot).argvs() + (('resize-pane', '-t', '%1', '-y', '20'), ('resize-pane', '-t', '%2', '-y', '20')) + """ + + node: _CommandPlanNode + + def to_chain(self, source: SnapshotSource) -> CommandChain: + """Resolve the query and compile mapped commands (pure). + + Parameters + ---------- + source : SnapshotSource + A :class:`TmuxSnapshot` or a :class:`SnapshotProvider`. + + Returns + ------- + CommandChain + + Raises + ------ + NoCommandsResolved + If the resolved query produced no commands. + ChainabilityError + If a mapped command is non-chainable -- its output would be + consumed mid-chain (e.g. ``show-option``). Raw ``CommandCall`` + composition via ``>>`` is the explicit escape hatch and is not + checked. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> panes().commands( + ... lambda p: p.cmd.resize_pane(height=10) + ... ).to_chain(snapshot).argvs() + (('resize-pane', '-t', '%1', '-y', '10'),) + """ + calls: list[CommandCall] = [] + for row in self.node.query.all(source): + calls.extend(_to_calls(self.node.mapper(row))) + if not calls: + msg = "command plan resolved to no commands" + raise NoCommandsResolved(msg) + for call in calls: + ensure_chainable(call.name) + return CommandChain(tuple(calls)) + + def run(self, runner: PlanRunner) -> None: + """Resolve, compile, and dispatch the plan in one tmux invocation. + + An empty plan is a no-op (it does not raise), mirroring libtmux's + lenient list-accessor contract. + + Examples + -------- + Dispatch ``send-keys`` to every active pane in one invocation, against + a live tmux server: + + >>> from libtmux._experimental.chain import SessionPlanExecutor + >>> plan = panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> plan.run(SessionPlanExecutor(session)) + """ + try: + sequence = self.to_chain(runner) + except NoCommandsResolved: + return None + sequence.run(runner) + return None + + def run_deferred(self, runner: PlanRunner) -> tuple[DeferredCommandResult, ...]: + r"""Dispatch once and return a resolved deferred result per command. + + The chain dispatches a single time; each returned + :class:`~libtmux._experimental.chain.chain.DeferredCommandResult` is + resolved with the chain's merged result (a ``\\;`` dispatch is not + separable per command, so every handle reflects the same result). An + empty plan returns an empty tuple. + + Examples + -------- + >>> from libtmux._experimental.chain import SessionPlanExecutor + >>> plan = panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> results = plan.run_deferred(SessionPlanExecutor(session)) + >>> all(r.returncode == 0 for r in results) + True + """ + try: + sequence = self.to_chain(runner) + except NoCommandsResolved: + return () + result = sequence.run(runner) + return tuple( + DeferredCommandResult(call).resolve(result) for call in sequence.calls + ) + + +def panes() -> PaneQuery: + """Start a lazy pane query. + + Examples + -------- + >>> panes() + PaneQuery(active_filter=None, ordering=None, limit_count=None) + """ + return PaneQuery() + + +def _resolve_snapshot(source: SnapshotSource) -> TmuxSnapshot: + if isinstance(source, TmuxSnapshot): + return source + return source.snapshot() + + +def _order_value(row: PaneRef, field: OrderField) -> str | int: + if field == "pane_id": + return row.pane_id.value + if field == "pane_index": + return row.pane_index + return row.title + + +def _to_calls(value: IntoCommands) -> tuple[CommandCall, ...]: + if isinstance(value, CommandCall): + return (value,) + if isinstance(value, CommandValue): + return (value.to_call(),) + if isinstance(value, str | bytes): + msg = "command mapper must return a command or iterable of commands" + raise TypeError(msg) + + calls: list[CommandCall] = [] + try: + iterator = iter(value) + except TypeError as exc: + msg = "command mapper must return a command or iterable of commands" + raise TypeError(msg) from exc + for item in iterator: + calls.extend(_to_calls(item)) + return tuple(calls) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 79170b750..b5c4afeb7 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -2356,6 +2356,8 @@ def break_pane( tmux_args += ("-n", "libtmux") tmux_args += ("-s", str(self.pane_id)) + if self.session_id is not None: + tmux_args += ("-t", f"{self.session_id}:") # Use server.cmd to avoid auto-adding -t from self.cmd proc = self.server.cmd("break-pane", *tmux_args) diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index fcc3ce052..6726b3062 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -167,7 +167,9 @@ def server( >>> pytester.makepyfile(**{'whatever.py': source}) PosixPath(...) - >>> result = pytester.runpytest('whatever.py', '--disable-warnings') + >>> result = pytester.runpytest( + ... 'whatever.py', '--disable-warnings', '-p', 'no:asyncio' + ... ) ===... >>> result.assert_outcomes(passed=1) @@ -212,7 +214,9 @@ def session_params() -> dict[str, t.Any]: >>> pytester.makepyfile(**{'whatever.py': source}) PosixPath(...) - >>> result = pytester.runpytest('whatever.py', '--disable-warnings') + >>> result = pytester.runpytest( + ... 'whatever.py', '--disable-warnings', '-p', 'no:asyncio' + ... ) ===... >>> result.assert_outcomes(passed=1) @@ -246,7 +250,9 @@ def session( >>> pytester.makepyfile(**{'whatever.py': source}) PosixPath(...) - >>> result = pytester.runpytest('whatever.py', '--disable-warnings') + >>> result = pytester.runpytest( + ... 'whatever.py', '--disable-warnings', '-p', 'no:asyncio' + ... ) ===... >>> result.assert_outcomes(passed=1) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 1b1dd49d4..a074cf800 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -869,19 +869,31 @@ def kill_window(self, target_window: str | int | None = None) -> None: Parameters ---------- target_window : str | int, optional - Window to kill. + Window to kill. A bare window name or index is scoped to this + session so it does not resolve against the server's current + session. Raises ------ :exc:`libtmux.exc.LibTmuxException` If tmux returns an error. + + Notes + ----- + A ``target_window`` string that contains ``:`` (or starts with ``@``) is + treated as an already-qualified tmux target and passed through + unchanged. tmux target syntax cannot distinguish a ``session:window`` + specifier from a window *name* that itself contains ``:``, so a window + whose name contains a colon must be killed via its ``@`` window id. """ target: str | int | None = target_window if target_window is not None: if isinstance(target_window, int): - target = f"{self.session_name}:{target_window}" + target = f"{self.session_id}:{target_window}" + elif target_window.startswith("@") or ":" in target_window: + target = target_window else: - target = f"{target_window}" + target = f"{self.session_id}:{target_window}" proc = self.cmd("kill-window", target=target) diff --git a/tests/_experimental/__init__.py b/tests/_experimental/__init__.py new file mode 100644 index 000000000..3b1f242bf --- /dev/null +++ b/tests/_experimental/__init__.py @@ -0,0 +1 @@ +"""Tests for experimental libtmux APIs.""" diff --git a/tests/_experimental/chain/__init__.py b/tests/_experimental/chain/__init__.py new file mode 100644 index 000000000..0569ad661 --- /dev/null +++ b/tests/_experimental/chain/__init__.py @@ -0,0 +1 @@ +"""Tests for the experimental chainable-commands API.""" diff --git a/tests/_experimental/chain/test_async.py b/tests/_experimental/chain/test_async.py new file mode 100644 index 000000000..9e397a687 --- /dev/null +++ b/tests/_experimental/chain/test_async.py @@ -0,0 +1,218 @@ +"""Tests for the async facade over deferred command plans.""" + +from __future__ import annotations + +import asyncio +import typing as t +from dataclasses import dataclass, field + +import pytest +from typing_extensions import assert_type + +from libtmux._experimental.chain import _async as api, plan as sync_plan +from libtmux._experimental.chain._connection import AsyncSessionPlanExecutor +from libtmux._experimental.chain.ir import CommandChain + +if t.TYPE_CHECKING: + from libtmux._experimental.chain.ir import Arg + from libtmux.session import Session + +# Strict asyncio_mode: mark every coroutine test in this module explicitly. +pytestmark = pytest.mark.asyncio + + +@dataclass +class _FakeResult: + """Minimal async command result.""" + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +@dataclass +class _AsyncFakeRunner: + """Async runner that exposes a snapshot and records dispatches.""" + + snapshot_value: sync_plan.TmuxSnapshot + calls: list[tuple[str, tuple[Arg, ...], str | int | None]] = field( + default_factory=list, + ) + snapshot_calls: int = 0 + + async def snapshot(self) -> sync_plan.TmuxSnapshot: + """Return the fixed tmux snapshot asynchronously.""" + await asyncio.sleep(0) + self.snapshot_calls += 1 + return self.snapshot_value + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> _FakeResult: + """Record one async command dispatch.""" + await asyncio.sleep(0) + self.calls.append((cmd, args, target)) + return _FakeResult(stdout=["async ok"]) + + +def _snapshot() -> sync_plan.TmuxSnapshot: + return sync_plan.TmuxSnapshot( + panes=( + sync_plan.PaneRef.concrete( + pane_id=sync_plan.PaneTarget("%2"), + window_id=sync_plan.WindowTarget("@1"), + session_id=sync_plan.SessionTarget("$0"), + pane_index=2, + active=True, + title="shell", + ), + sync_plan.PaneRef.concrete( + pane_id=sync_plan.PaneTarget("%1"), + window_id=sync_plan.WindowTarget("@1"), + session_id=sync_plan.SessionTarget("$0"), + pane_index=1, + active=True, + title="editor", + ), + sync_plan.PaneRef.concrete( + pane_id=sync_plan.PaneTarget("%3"), + window_id=sync_plan.WindowTarget("@2"), + session_id=sync_plan.SessionTarget("$0"), + pane_index=3, + active=False, + title="logs", + ), + ), + ) + + +async def test_async_to_chain_awaits_snapshot_without_dispatching() -> None: + """Async plan inspection preserves the pure command-assertion workflow.""" + runner = _AsyncFakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands( + lambda pane: [ + pane.cmd.send_keys("clear", enter=True), + pane.window.select_layout("even-horizontal"), + ], + ) + ) + + sequence = await plan.to_chain(runner) + + assert_type(plan, api.CommandPlan) + assert_type(sequence, CommandChain) + assert sequence.argvs() == ( + ("send-keys", "-t", "%1", "clear", "Enter"), + ("select-layout", "-t", "@1", "even-horizontal"), + ("send-keys", "-t", "%2", "clear", "Enter"), + ("select-layout", "-t", "@1", "even-horizontal"), + ) + assert runner.snapshot_calls == 1 + assert runner.calls == [] + + +async def test_async_run_dispatches_one_native_tmux_sequence() -> None: + """Async execution still chains concrete commands into one tmux call.""" + runner = _AsyncFakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands(lambda pane: pane.cmd.resize_pane(height=20)) + ) + + await plan.run(runner) + + assert runner.calls == [ + ( + "resize-pane", + ( + "-t", + "%1", + "-y", + "20", + ";", + "resize-pane", + "-t", + "%2", + "-y", + "20", + ), + None, + ), + ] + + +async def test_async_map_and_first_are_data_only() -> None: + """Async row transforms stay separate from command construction.""" + runner = _AsyncFakeRunner(_snapshot()) + query = api.panes().filter(active=True).order_by("pane_index") + + titles = await query.map(lambda pane: pane.title).all(runner) + first = await query.first(runner) + + assert_type(titles, list[str]) + assert titles == ["editor", "shell"] + assert first is not None + assert first.pane_id.value == "%1" + assert runner.calls == [] + + +async def test_async_empty_plan_to_chain_raises_but_run_is_noop() -> None: + """Async empty plans match the sync no-op execution behavior.""" + runner = _AsyncFakeRunner(sync_plan.TmuxSnapshot(panes=())) + plan = api.panes().commands(lambda pane: pane.cmd.resize_pane(height=20)) + + with pytest.raises(api.NoCommandsResolved): + await plan.to_chain(runner) + + await plan.run(runner) + assert runner.calls == [] + + +async def test_async_session_plan_runner_dispatches_against_live_tmux( + session: Session, +) -> None: + """The async live adapter resolves and dispatches against a real server.""" + session.active_window.split() + runner = AsyncSessionPlanExecutor(session) + + snapshot = await runner.snapshot() + assert len(snapshot.panes) >= 2 + + plan = api.panes().commands( + lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ) + sequence = await plan.to_chain(runner) + + # Every active and inactive pane in the snapshot is targeted. + targeted = {argv[2] for argv in sequence.argvs()} + assert targeted == {pane.pane_id.value for pane in snapshot.panes} + + # Dispatches once through the worker thread without raising. + await plan.run(runner) + + +async def test_async_run_deferred_resolves_one_handle_per_command() -> None: + """Async ``run_deferred`` dispatches once and resolves a handle per command.""" + runner = _AsyncFakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .commands( + lambda pane: pane.cmd.send_keys("clear", enter=True), + ) + ) + + results = await plan.run_deferred(runner) + + assert len(runner.calls) == 1 # the whole chain dispatched once + assert [r.returncode for r in results] == [0, 0] + assert results[0].stdout == ["async ok"] # the chain's merged result diff --git a/tests/_experimental/chain/test_chain.py b/tests/_experimental/chain/test_chain.py new file mode 100644 index 000000000..4c8813886 --- /dev/null +++ b/tests/_experimental/chain/test_chain.py @@ -0,0 +1,139 @@ +"""Tests for the chainability contract.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import sys +import typing as t + +import pytest + +from libtmux._experimental.chain.chain import ( + DeferredCommandResult, + DeferredOutputUnavailable, + is_chainable, +) +from libtmux._experimental.chain.ir import CommandCall + + +class MinimalImportCase(t.NamedTuple): + """A module import that must work without optional dependency groups.""" + + test_id: str + module: str + + +MINIMAL_IMPORT_CASES = ( + MinimalImportCase( + test_id="chain-package", + module="libtmux._experimental.chain", + ), +) + + +class ChainabilityCase(t.NamedTuple): + """A command name and expected chainability.""" + + test_id: str + command: str + expected: bool + + +CHAINABILITY_CASES = ( + ChainabilityCase( + test_id="known-chainable", + command="rename-window", + expected=True, + ), + ChainabilityCase( + test_id="layout-chainable", + command="select-layout", + expected=True, + ), + ChainabilityCase( + test_id="output-show-option", + command="show-option", + expected=False, + ), + ChainabilityCase( + test_id="output-capture-pane", + command="capture-pane", + expected=False, + ), + ChainabilityCase( + test_id="unknown-fail-closed", + command="some-unknown-command", + expected=False, + ), +) + + +@pytest.mark.parametrize( + "case", + MINIMAL_IMPORT_CASES, + ids=[case.test_id for case in MINIMAL_IMPORT_CASES], +) +def test_minimal_import_without_dev_dependency_groups( + case: MinimalImportCase, +) -> None: + """The experimental chain package imports with only stdlib dependencies.""" + project_root = pathlib.Path(__file__).parents[3] + env = os.environ.copy() + env["PYTHONPATH"] = str(project_root / "src") + + proc = subprocess.run( + [sys.executable, "-S", "-c", f"import {case.module}"], + check=False, + capture_output=True, + env=env, + text=True, + ) + + assert proc.returncode == 0, proc.stderr + + +@pytest.mark.parametrize( + "case", + CHAINABILITY_CASES, + ids=[case.test_id for case in CHAINABILITY_CASES], +) +def test_is_chainable_uses_static_spec(case: ChainabilityCase) -> None: + """The static ``chainable`` flag decides what may fold into a chain.""" + assert is_chainable(case.command) is case.expected + + +def test_deferred_result_rejects_output_access() -> None: + """An unresolved deferred result has no output until the chain runs.""" + result = DeferredCommandResult(CommandCall("rename-window", ("work",))) + + with pytest.raises(DeferredOutputUnavailable): + _ = result.stdout + with pytest.raises(DeferredOutputUnavailable): + _ = result.stderr + with pytest.raises(DeferredOutputUnavailable): + _ = result.returncode + + +class _MergedResult: + """Minimal merged chain result for resolution tests.""" + + def __init__(self) -> None: + self.stdout = ["ok"] + self.stderr: list[str] = [] + self.returncode = 0 + + +def test_deferred_result_resolves_to_chain_result() -> None: + """A resolved deferred result hands back the chain's merged result.""" + pending = DeferredCommandResult(CommandCall("rename-window", ("work",))) + + resolved = pending.resolve(_MergedResult()) + + assert resolved.returncode == 0 + assert resolved.stdout == ["ok"] + assert resolved.stderr == [] + # The original handle stays unresolved (immutable). + with pytest.raises(DeferredOutputUnavailable): + _ = pending.returncode diff --git a/tests/_experimental/chain/test_connection.py b/tests/_experimental/chain/test_connection.py new file mode 100644 index 000000000..d0b6a014b --- /dev/null +++ b/tests/_experimental/chain/test_connection.py @@ -0,0 +1,73 @@ +"""Live-tmux integration tests for the chain connection layer.""" + +from __future__ import annotations + +import typing as t + +from libtmux._experimental.chain._connection import ( + SessionPlanExecutor, + snapshot_from_session, +) +from libtmux._experimental.chain.plan import PaneTarget, panes + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_snapshot_from_session_reads_live_panes(session: Session) -> None: + """A snapshot reflects the session's real panes with typed targets.""" + session.active_window.split() + + snapshot = snapshot_from_session(session) + + assert len(snapshot.panes) >= 2 + for pane in snapshot.panes: + assert isinstance(pane.pane_id, PaneTarget) + assert pane.pane_id.value.startswith("%") + assert pane.window_id.value.startswith("@") + assert pane.session_id.value.startswith("$") + + +def test_session_plan_runner_compiles_real_targets(session: Session) -> None: + """A plan resolved through the runner targets the session's real panes.""" + session.active_window.split() + runner = SessionPlanExecutor(session) + snapshot = runner.snapshot() + + plan = panes().commands(lambda pane: pane.cmd.send_keys("echo cc", enter=True)) + sequence = plan.to_chain(runner) + + # Every compiled command targets a real pane id from the live snapshot. + targeted = {argv[2] for argv in sequence.argvs()} + assert targeted == {pane.pane_id.value for pane in snapshot.panes} + + +def test_session_plan_runner_dispatches_plan_once(session: Session) -> None: + """Running a plan dispatches against the live server without error.""" + session.active_window.split() + runner = SessionPlanExecutor(session) + + plan = ( + panes() + .filter(active=True) + .commands( + lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ) + ) + + # Resolves the live snapshot and dispatches as one native tmux invocation. + # ``run`` returns ``None``; this asserts the dispatch raises nothing. + plan.run(runner) + + +def test_empty_plan_run_is_noop_against_live_session(session: Session) -> None: + """A plan that resolves to no commands is a live no-op.""" + runner = SessionPlanExecutor(session) + + # ``limit(0)`` yields no rows, so the plan resolves to no commands. + plan = ( + panes().limit(0).commands(lambda pane: pane.cmd.send_keys("echo x", enter=True)) + ) + + # No commands resolved -> a silent no-op (does not raise). + plan.run(runner) diff --git a/tests/_experimental/chain/test_control.py b/tests/_experimental/chain/test_control.py new file mode 100644 index 000000000..56219f3da --- /dev/null +++ b/tests/_experimental/chain/test_control.py @@ -0,0 +1,166 @@ +"""Tests for the experimental chain control-mode runner.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux._experimental.chain.control import ( + ControlModeBlock, + ControlModeParser, + ControlModeRunner, +) +from libtmux._experimental.chain.ir import CommandCall + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class ParserCase(t.NamedTuple): + """A control-mode wire payload and expected blocks.""" + + test_id: str + wire: bytes + expected: tuple[ControlModeBlock, ...] + + +PARSER_CASES = ( + ParserCase( + test_id="success-block", + wire=b"%begin 1 7 1\nhello\n%end 1 7 1\n", + expected=( + ControlModeBlock( + number=7, + flags=1, + is_error=False, + body=(b"hello",), + ), + ), + ), + ParserCase( + test_id="error-block", + wire=b"%begin 1 8 1\nbad command\n%error 1 8 1\n", + expected=( + ControlModeBlock( + number=8, + flags=1, + is_error=True, + body=(b"bad command",), + ), + ), + ), + ParserCase( + test_id="pane-id-output", + wire=b"%begin 1 9 1\n%42\n%end 1 9 1\n", + expected=( + ControlModeBlock( + number=9, + flags=1, + is_error=False, + body=(b"%42",), + ), + ), + ), + ParserCase( + test_id="event-shaped-output", + wire=b"%begin 1 10 1\n%output literal\n%message literal\n%end 1 10 1\n", + expected=( + ControlModeBlock( + number=10, + flags=1, + is_error=False, + body=(b"%output literal", b"%message literal"), + ), + ), + ), + ParserCase( + test_id="mismatched-end-shaped-output", + wire=b"%begin 1 11 1\n%end 1 12 1\nstill here\n%end 1 11 1\n", + expected=( + ControlModeBlock( + number=11, + flags=1, + is_error=False, + body=(b"%end 1 12 1", b"still here"), + ), + ), + ), +) + + +@pytest.mark.parametrize( + "case", + PARSER_CASES, + ids=[case.test_id for case in PARSER_CASES], +) +def test_control_mode_parser_emits_blocks(case: ParserCase) -> None: + """The parser preserves block bodies and error status.""" + parser = ControlModeParser() + + parser.feed(case.wire) + + assert tuple(parser.blocks()) == case.expected + + +def test_control_mode_runner_empty_batch_does_not_spawn(session: Session) -> None: + """An empty control-mode batch is a no-op.""" + runner = ControlModeRunner(session.server) + try: + assert runner.run_argvs([]) == [] + assert runner._proc is None + finally: + runner.close() + + +def test_control_mode_runner_batch_returns_per_command_stdout( + session: Session, +) -> None: + """A control-mode batch returns one output result per command.""" + with ControlModeRunner(session.server) as runner: + results = runner.run_argvs( + [ + ("display-message", "-p", "first"), + ("display-message", "-p", "second"), + ], + ) + + assert [result.returncode for result in results] == [0, 0] + assert [result.stdout for result in results] == [["first"], ["second"]] + assert [result.stderr for result in results] == [[], []] + + +def test_control_mode_runner_chain_returns_per_call_stdout( + session: Session, +) -> None: + """A ``CommandChain`` can run over control mode without merged output.""" + chain = CommandCall("display-message", ("-p", "left")).then( + CommandCall("display-message", ("-p", "right")), + ) + + with ControlModeRunner(session.server) as runner: + results = runner.run_chain(chain) + + assert [result.stdout for result in results] == [["left"], ["right"]] + + +def test_control_mode_runner_mid_batch_error_keeps_later_results( + session: Session, +) -> None: + """A bad command in a control-mode batch does not consume later results.""" + with ControlModeRunner(session.server) as runner: + before, bad, after = runner.run_argvs( + [ + ("display-message", "-p", "before"), + ("no-such-command",), + ("display-message", "-p", "after"), + ], + ) + + assert before.returncode == 0 + assert before.stdout == ["before"] + assert bad.returncode == 1 + assert bad.stdout == [] + assert bad.stderr + assert after.returncode == 0 + assert after.stdout == ["after"] diff --git a/tests/_experimental/chain/test_forward.py b/tests/_experimental/chain/test_forward.py new file mode 100644 index 000000000..dba3116da --- /dev/null +++ b/tests/_experimental/chain/test_forward.py @@ -0,0 +1,153 @@ +"""Forward (lazily-resolved) refs: the dual-purpose PaneRef/WindowRef/SessionRef.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux._experimental.chain import ( + ForwardDataUnavailable, + PaneRef, + SessionPlanExecutor, + new_session, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _seed(pane: t.Any) -> PaneRef: + return PaneRef.concrete( + pane_id=pane.pane_id, + window_id=pane.window_id, + session_id=pane.session_id, + pane_index=int(pane.pane_index or 0), + active=pane.pane_active == "1", + title=pane.pane_title or "", + ) + + +def test_forward_is_same_type_reuses_namespaces_and_guards_metadata() -> None: + """A forward ref is a PaneRef; .cmd is reused; metadata is guarded.""" + seed = PaneRef.concrete( + pane_id="%1", + window_id="@1", + session_id="$0", + pane_index=0, + active=True, + title="editor", + ) + child = seed.split(horizontal=True) + + assert isinstance(child, PaneRef) # SAME type, not a parallel object + assert child.is_forward and not seed.is_forward + # The pane id is pending (no -t = active), but the window is still the + # concrete @1 the split happened in -- propagated, so it stays addressable: + assert child.cmd.send_keys("htop").argv() == ("send-keys", "htop") + assert child.window.select_layout("tiled").argv() == ( + "select-layout", + "-t", + "@1", + "tiled", + ) + + assert seed.title == "editor" # concrete metadata: typed str + with pytest.raises(ForwardDataUnavailable): + _ = child.title # forward metadata: guarded (pending-attribute pattern) + + +def test_forward_do_threads_commands_and_compiles_one_chain() -> None: + """`.do()` reuses the namespaces fluently and compiles to one chain (pure).""" + seed = PaneRef.concrete( + pane_id="%1", + window_id="@1", + session_id="$0", + pane_index=0, + active=True, + title="x", + ) + plan = ( + seed.split(horizontal=True) # split %1 -> forward B + .split() # split B -> forward C + .do(lambda p: p.cmd.send_keys("htop", enter=True)) # reuse .cmd + ) + assert plan.to_chain().argvs() == ( + ("split-window", "-t", "%1", "-h"), + ("split-window", "-v"), + ("send-keys", "htop", "Enter"), + ) + + +def test_forward_pane_resolves_to_newly_created_pane(session: Session) -> None: + """Live: split a pane, split that pane, mark the deepest -- one dispatch.""" + window = session.new_window(window_name="fwd_pane") + seed_pane = window.active_pane + assert seed_pane is not None + + ( + _seed(seed_pane) + .split(horizontal=True) + .split() + .do(lambda p: p.cmd.raw("set-option", "-p", "@cc_mark", "DEEP")) + .run(session.server) + ) + + window.refresh() + assert len(window.panes) == 3 + marked = [ + p + for p in window.panes + if "DEEP" in p.cmd("display-message", "-p", "#{@cc_mark}").stdout + ] + assert len(marked) == 1 + assert marked[0].pane_active == "1" + assert marked[0].pane_id != seed_pane.pane_id # forward ref resolved + + +def test_forward_window_scope_creates_window_then_splits(session: Session) -> None: + """Live: session -> new_window (forward WindowRef) -> split inside it.""" + from libtmux._experimental.chain import SessionRef, SessionTarget + + assert session.session_id is not None + sref = SessionRef.concrete( + session_id=SessionTarget(session.session_id), + session_name=session.session_name or "", + ) + n_before = len(session.windows) + + sref.new_window(name="fwd_win").split(horizontal=True).run(session.server) + + session.refresh() + assert len(session.windows) == n_before + 1 + new_win = next(w for w in session.windows if w.window_name == "fwd_win") + new_win.refresh() + assert len(new_win.panes) == 2 # the new window's pane was split + + +def test_forward_session_scope_creates_session(session: Session) -> None: + """Live: new_session (forward SessionRef) -> new_window, one dispatch.""" + server = session.server + name = "cc_v2_fwd_sess" + try: + new_session(name=name).new_window(name="built").run(server) + + created = next((s for s in server.sessions if s.session_name == name), None) + assert created is not None # the forward session was created + assert "built" in {w.window_name for w in created.windows} + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_forward_paneref_runs_through_executor(session: Session) -> None: + """A forward plan also dispatches through SessionPlanExecutor.""" + window = session.new_window(window_name="fwd_exec") + seed_pane = window.active_pane + assert seed_pane is not None + + _seed(seed_pane).split().split().run(SessionPlanExecutor(session)) + + window.refresh() + assert len(window.panes) == 3 diff --git a/tests/_experimental/chain/test_ir.py b/tests/_experimental/chain/test_ir.py new file mode 100644 index 000000000..4bfac2a00 --- /dev/null +++ b/tests/_experimental/chain/test_ir.py @@ -0,0 +1,203 @@ +"""Tests for the chainable-commands argv intermediate representation.""" + +from __future__ import annotations + +import logging +import typing as t +from dataclasses import dataclass, field + +import pytest + +from libtmux._experimental.chain.ir import ( + CommandCall, + CommandChain, + CommandSpec, +) + +if t.TYPE_CHECKING: + from libtmux._experimental.chain.ir import Arg + from libtmux.session import Session + + +@dataclass +class _FakeResult: + """Minimal command result for runner tests.""" + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +@dataclass +class _FakeRunner: + """Runner that records dispatches instead of touching tmux.""" + + calls: list[tuple[str, tuple[Arg, ...], str | int | None]] = field( + default_factory=list, + ) + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> _FakeResult: + """Record one command dispatch.""" + self.calls.append((cmd, args, target)) + return _FakeResult(stdout=["ok"]) + + +def _read_global_option(session: Session, name: str) -> list[str]: + """Read a global tmux option through the experimental IR. + + Dogfoods :class:`CommandChain` for the read-back so the assertion goes + through the typed ``CommandRunner`` protocol rather than a raw + ``server.cmd`` call. + """ + readback = CommandChain((CommandCall("show-option", ("-gv", name)),)) + return readback.run(session.server).stdout + + +def test_command_call_renders_argv() -> None: + """A call renders its name and positional arguments in order.""" + call = CommandCall("new-window", ("-d", "-n", "work")) + + assert call.argv() == ("new-window", "-d", "-n", "work") + + +def test_command_call_injects_target_after_name() -> None: + """A target renders as a ``-t`` flag immediately after the command name.""" + call = CommandCall("split-window", ("-h",), target="%1") + + assert call.argv() == ("split-window", "-t", "%1", "-h") + + +def test_command_call_renders_integer_arguments() -> None: + """Integer argument tokens render via :func:`str`.""" + call = CommandCall("resize-pane", ("-y", 20), target="%1") + + assert call.argv() == ("resize-pane", "-t", "%1", "-y", "20") + + +def test_command_call_rejects_empty_string_target() -> None: + """An empty-string target is rejected; ``None`` and ints are allowed.""" + with pytest.raises(ValueError, match="non-empty string or None"): + CommandCall("kill-window", target="") + + # None (no target) and integer targets remain valid. + assert CommandCall("list-panes").argv() == ("list-panes",) + assert CommandCall("select-window", target=0).argv() == ( + "select-window", + "-t", + "0", + ) + + +def test_command_sequence_renders_tmux_semicolon_sequence() -> None: + """Composed calls render with standalone ``;`` separator tokens.""" + sequence = CommandCall("new-window", ("-d",)) >> CommandCall( + "split-window", + ("-h",), + ) + + assert sequence.argv() == ( + "new-window", + "-d", + ";", + "split-window", + "-h", + ) + + +def test_command_sequence_argvs_renders_each_call_independently() -> None: + """``argvs`` keeps per-call argv tuples for easy assertions.""" + sequence = CommandCall("rename-window", ("work",)) >> CommandCall("split-window") + + assert sequence.argvs() == ( + ("rename-window", "work"), + ("split-window",), + ) + + +def test_command_sequence_escapes_literal_semicolon_arguments() -> None: + """A literal trailing ``;`` is escaped so tmux does not split on it.""" + sequence = CommandChain( + (CommandCall("send-keys", ("echo hi;",), target="%1"),), + ) + + assert sequence.argv() == ("send-keys", "-t", "%1", "echo hi\\;") + + +def test_command_sequence_rejects_empty() -> None: + """An empty sequence is a programming error.""" + with pytest.raises(ValueError, match="at least one call"): + CommandChain(()) + + +def test_command_sequence_runs_as_single_runner_call() -> None: + """``run`` dispatches the whole sequence through one runner call.""" + runner = _FakeRunner() + sequence = CommandCall("new-window", ("-d",)) >> CommandCall("split-window") + + sequence.run(runner) + + assert runner.calls == [ + ("new-window", ("-d", ";", "split-window"), None), + ] + + +def test_command_sequence_run_logs_structured_dispatch( + caplog: pytest.LogCaptureFixture, +) -> None: + """``run`` emits a debug record carrying the rendered tmux command.""" + runner = _FakeRunner() + sequence = CommandCall("new-window", ("-d",)) >> CommandCall("split-window") + + with caplog.at_level(logging.DEBUG, logger="libtmux._experimental.chain.ir"): + sequence.run(runner) + + dispatched = [r for r in caplog.records if hasattr(r, "tmux_cmd")] + assert dispatched + record = t.cast("t.Any", dispatched[0]) + assert record.tmux_cmd == "new-window -d ; split-window" + assert record.tmux_subcommand == "new-window" + + completed = [r for r in caplog.records if hasattr(r, "tmux_exit_code")] + assert completed + assert t.cast("t.Any", completed[0]).tmux_exit_code == 0 + + +def test_command_spec_defaults_to_chainable() -> None: + """Specs are chainable unless a command must return output immediately.""" + assert CommandSpec(name="rename-window", scope="window").chainable is True + assert ( + CommandSpec(name="show-option", scope="server", chainable=False).chainable + is False + ) + + +def test_tmux_executes_native_command_sequence(session: Session) -> None: + """A sequence dispatches as one native tmux invocation against a server.""" + sequence = CommandCall( + "set-option", + ("-g", "@cc_ir_a", "1"), + ) >> CommandCall("set-option", ("-g", "@cc_ir_b", "2")) + + result = sequence.run(session.server) + + assert result.returncode == 0 + assert _read_global_option(session, "@cc_ir_b") == ["2"] + + +def test_tmux_stops_native_sequence_after_error(session: Session) -> None: + """Tmux skips later commands in a sequence once one errors.""" + sequence = CommandCall( + "set-option", + ("-g", "@cc_ir_marker", "set"), + ) >> CommandCall("has-session", ("-t", "cc_definitely_missing_session")) + + result = sequence.run(session.server) + + assert result.returncode != 0 + # The first command ran before the erroring one stopped the rest. + assert _read_global_option(session, "@cc_ir_marker") == ["set"] diff --git a/tests/_experimental/chain/test_plan.py b/tests/_experimental/chain/test_plan.py new file mode 100644 index 000000000..19624e2f6 --- /dev/null +++ b/tests/_experimental/chain/test_plan.py @@ -0,0 +1,394 @@ +"""Tests for the deferred query-command plan layer.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +import pytest +from typing_extensions import assert_type + +from libtmux._experimental.chain import plan as api +from libtmux._experimental.chain.chain import ( + ChainabilityError, + CommandScopeError, + DeferredCommandResult, +) +from libtmux._experimental.chain.ir import CommandCall, CommandChain + +if t.TYPE_CHECKING: + from libtmux._experimental.chain.ir import Arg + + +@dataclass +class _FakeResult: + """Minimal command result for plan runner tests.""" + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +@dataclass +class _FakeRunner: + """Runner exposing a fixed snapshot and recording dispatches.""" + + snapshot_value: api.TmuxSnapshot + calls: list[tuple[str, tuple[Arg, ...], str | int | None]] = field( + default_factory=list, + ) + + def snapshot(self) -> api.TmuxSnapshot: + """Return the fixed tmux snapshot.""" + return self.snapshot_value + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> _FakeResult: + """Record one command dispatch.""" + self.calls.append((cmd, args, target)) + return _FakeResult(stdout=["ok"]) + + +def _snapshot() -> api.TmuxSnapshot: + return api.TmuxSnapshot( + panes=( + api.PaneRef.concrete( + pane_id=api.PaneTarget("%2"), + window_id=api.WindowTarget("@1"), + session_id=api.SessionTarget("$0"), + pane_index=2, + active=True, + title="shell", + ), + api.PaneRef.concrete( + pane_id=api.PaneTarget("%1"), + window_id=api.WindowTarget("@1"), + session_id=api.SessionTarget("$0"), + pane_index=1, + active=True, + title="editor", + ), + api.PaneRef.concrete( + pane_id=api.PaneTarget("%3"), + window_id=api.WindowTarget("@2"), + session_id=api.SessionTarget("$0"), + pane_index=3, + active=False, + title="logs", + ), + ), + ) + + +def test_typed_targets_and_bound_commands_render_targets() -> None: + """Bound command namespaces keep pane and window targets typed.""" + pane = _snapshot().panes[0] + + pane_call = pane.cmd.send_keys("clear", enter=True) + window_call = pane.window.select_layout("even-horizontal") + + assert_type(pane.pane_id, api.PaneTargetT) + assert_type(pane.window_id, api.WindowTargetT) + assert_type(pane.session_id, api.SessionTargetT) + assert pane_call.argv() == ("send-keys", "-t", "%2", "clear", "Enter") + assert window_call.argv() == ("select-layout", "-t", "@1", "even-horizontal") + + +def test_commands_defers_mapper_until_sequence_resolution() -> None: + """``commands`` stores a plan node instead of eagerly calling the mapper.""" + mapper_calls: list[api.PaneRef] = [] + + def mapper(pane: api.PaneRef) -> api.CommandValue: + mapper_calls.append(pane) + return pane.cmd.resize_pane(height=20) + + plan = api.panes().filter(active=True).commands(mapper) + + assert_type(plan, api.CommandPlan) + assert mapper_calls == [] + + sequence = plan.to_chain(_snapshot()) + + assert [pane.pane_id.value for pane in mapper_calls] == ["%2", "%1"] + assert sequence.argvs() == ( + ("resize-pane", "-t", "%2", "-y", "20"), + ("resize-pane", "-t", "%1", "-y", "20"), + ) + + +def test_snapshot_sequence_filters_orders_and_flattens_commands() -> None: + """Snapshot compilation gives pure assertions without touching tmux.""" + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands( + lambda pane: [ + pane.cmd.send_keys("clear", enter=True), + pane.cmd.resize_pane(height=20), + ], + ) + ) + + sequence = plan.to_chain(_snapshot()) + + assert_type(sequence, CommandChain) + assert sequence.argvs() == ( + ("send-keys", "-t", "%1", "clear", "Enter"), + ("resize-pane", "-t", "%1", "-y", "20"), + ("send-keys", "-t", "%2", "clear", "Enter"), + ("resize-pane", "-t", "%2", "-y", "20"), + ) + assert sequence.argv()[:6] == ( + "send-keys", + "-t", + "%1", + "clear", + "Enter", + ";", + ) + + +def test_commands_supports_multiple_commands_per_row() -> None: + """``commands`` exposes the explicit multi-command row expansion.""" + plan = ( + api.panes() + .filter(active=True) + .commands( + lambda pane: ( + pane.cmd.resize_pane(height=10), + pane.window.select_layout("even-horizontal"), + ), + ) + ) + + assert plan.to_chain(_snapshot()).argvs() == ( + ("resize-pane", "-t", "%2", "-y", "10"), + ("select-layout", "-t", "@1", "even-horizontal"), + ("resize-pane", "-t", "%1", "-y", "10"), + ("select-layout", "-t", "@1", "even-horizontal"), + ) + + +def test_map_transforms_rows_without_creating_commands() -> None: + """``map`` remains data-oriented and separate from command construction.""" + query = api.panes().filter(active=True).order_by("pane_index") + + titles = query.map(lambda pane: pane.title).all(_snapshot()) + + assert_type(titles, list[str]) + assert titles == ["editor", "shell"] + + +def test_run_resolves_live_snapshot_and_dispatches_once() -> None: + """``run`` resolves the query and executes one native tmux sequence.""" + runner = _FakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands(lambda pane: pane.cmd.send_keys("clear", enter=True)) + ) + + plan.run(runner) + + assert runner.calls == [ + ( + "send-keys", + ( + "-t", + "%1", + "clear", + "Enter", + ";", + "send-keys", + "-t", + "%2", + "clear", + "Enter", + ), + None, + ), + ] + + +def test_to_chain_uses_runner_snapshot_without_dispatching() -> None: + """Resolving against a runner still keeps execution explicit.""" + runner = _FakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .commands(lambda pane: pane.cmd.resize_pane(height=12)) + ) + + sequence = plan.to_chain(runner) + + assert sequence.argvs() == ( + ("resize-pane", "-t", "%2", "-y", "12"), + ("resize-pane", "-t", "%1", "-y", "12"), + ) + assert runner.calls == [] + + +def test_empty_query_to_chain_raises_but_run_is_noop() -> None: + """Empty query plans are inspectably empty and executable as no-ops.""" + runner = _FakeRunner(api.TmuxSnapshot(panes=())) + plan = api.panes().commands(lambda pane: pane.cmd.resize_pane(height=20)) + + with pytest.raises(api.NoCommandsResolved): + plan.to_chain(runner) + + plan.run(runner) + assert runner.calls == [] + + +def test_commands_rejects_string_iterable_command_results() -> None: + """String-like mapper results are not accepted as command iterables.""" + plan = api.panes().commands(lambda pane: t.cast("t.Any", pane.title)) + + with pytest.raises(TypeError, match="command mapper"): + plan.to_chain(_snapshot()) + + +def test_to_chain_rejects_nonchainable_command() -> None: + """A plan mapping a row to a non-chainable command raises. + + ``show-option`` returns output that would be consumed mid-chain, so folding + it into a one-dispatch sequence is rejected at compile time -- the + chainability contract is enforced, not merely advertised. + """ + plan = api.panes().commands( + lambda pane: CommandCall( + "show-option", + ("-gv", "@x"), + target=pane.pane_id.value, + ), + ) + + with pytest.raises(ChainabilityError, match="not chainable"): + plan.to_chain(_snapshot()) + + +def test_to_chain_rejects_unknown_raw_command() -> None: + """The raw escape hatch may not fold unregistered commands.""" + plan = ( + api.panes().limit(1).commands(lambda pane: pane.cmd.raw("some-unknown-command")) + ) + + with pytest.raises(ChainabilityError, match="unknown tmux command"): + plan.to_chain(_snapshot()) + + +def test_to_chain_allows_chainable_command() -> None: + """A chainable raw command compiles without raising.""" + plan = ( + api.panes() + .limit(1) + .commands( + lambda pane: CommandCall("rename-window", ("work",)), + ) + ) + + assert plan.to_chain(_snapshot()).argvs() == (("rename-window", "work"),) + + +def test_raw_escape_hatch_binds_typed_targets() -> None: + """``raw`` issues an arbitrary command bound to each scope's typed target.""" + pane = _snapshot().panes[0] + + assert pane.cmd.raw("pipe-pane", "-o").argv() == ("pipe-pane", "-t", "%2", "-o") + assert pane.window.raw("set-option", "@x", "1").argv() == ( + "set-option", + "-t", + "@1", + "@x", + "1", + ) + assert pane.session.raw("set-option", "automatic-rename", "on").argv() == ( + "set-option", + "-t", + "$0", + "automatic-rename", + "on", + ) + + +class ScopeRejectionCase(t.NamedTuple): + """A known command bound to a wrong typed target scope.""" + + test_id: str + build: t.Callable[[api.PaneRef], object] + + +SCOPE_REJECTION_CASES = ( + ScopeRejectionCase( + test_id="pane-target-window-command", + build=lambda pane: pane.cmd.raw("rename-window", "bad"), + ), + ScopeRejectionCase( + test_id="window-target-pane-command", + build=lambda pane: pane.window.raw("send-keys", "bad"), + ), + ScopeRejectionCase( + test_id="session-target-pane-command", + build=lambda pane: pane.session.raw("resize-pane", "-y", 20), + ), +) + + +@pytest.mark.parametrize( + "case", + SCOPE_REJECTION_CASES, + ids=[case.test_id for case in SCOPE_REJECTION_CASES], +) +def test_raw_escape_hatch_rejects_wrong_known_scope( + case: ScopeRejectionCase, +) -> None: + """Known commands cannot bind to the wrong typed target namespace.""" + pane = _snapshot().panes[0] + + with pytest.raises(CommandScopeError, match="cannot target"): + case.build(pane) + + +def test_raw_escape_hatch_still_enforces_chainability() -> None: + """A non-chainable command via the escape hatch is still rejected.""" + plan = ( + api.panes().limit(1).commands(lambda pane: pane.cmd.raw("capture-pane", "-p")) + ) + + with pytest.raises(ChainabilityError, match="not chainable"): + plan.to_chain(_snapshot()) + + +def test_run_deferred_resolves_one_handle_per_command() -> None: + """``run_deferred`` dispatches once and resolves a handle per command.""" + runner = _FakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .commands( + lambda pane: pane.cmd.send_keys("clear", enter=True), + ) + ) + + results = plan.run_deferred(runner) + + assert len(runner.calls) == 1 # the whole chain dispatched once + assert len(results) == 2 # one handle per active pane + assert all(isinstance(r, DeferredCommandResult) for r in results) + assert [r.returncode for r in results] == [0, 0] + assert results[0].stdout == ["ok"] # the chain's merged result + + +def test_run_deferred_empty_plan_returns_empty() -> None: + """An empty plan dispatches nothing and returns no handles.""" + runner = _FakeRunner(api.TmuxSnapshot(panes=())) + plan = api.panes().commands(lambda pane: pane.cmd.resize_pane(height=10)) + + assert plan.run_deferred(runner) == () + assert runner.calls == [] diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py new file mode 100644 index 000000000..33491e2bd --- /dev/null +++ b/tests/_experimental/chain/test_resolve.py @@ -0,0 +1,646 @@ +"""Multi-dispatch resolution: independent forward handles, sync + async.""" + +from __future__ import annotations + +import pathlib +import typing as t +from dataclasses import dataclass, field + +import pytest + +from libtmux._experimental.chain import ( + AsyncSessionPlanExecutor, + ChainabilityError, + ForwardPlan, + ServerPlanRunner, + SessionPlanExecutor, + panes, +) +from libtmux._experimental.chain._resolve import ( + Dispatch, + ForwardDispatchError, + _marked_eligible, + drive, +) +from libtmux._experimental.chain.plan import PaneTarget + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +@dataclass +class _FakeResult: + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +def _mark(value: str) -> t.Callable[[t.Any], t.Any]: + return lambda h: h.cmd.raw("set-option", "-p", "@m", value) + + +class ForwardDecorateChainabilityCase(t.NamedTuple): + """A forward decoration that must fail chainability validation.""" + + test_id: str + build: t.Callable[[t.Any], t.Any] + match: str + + +FORWARD_DECORATE_CHAINABILITY_CASES = ( + ForwardDecorateChainabilityCase( + test_id="output-command", + build=lambda h: h.cmd.raw("capture-pane", "-p"), + match="not chainable", + ), + ForwardDecorateChainabilityCase( + test_id="unknown-command", + build=lambda h: h.cmd.raw("some-unknown-command"), + match="unknown tmux command", + ), +) + + +def test_drive_core_is_sans_io_and_substitutes_ids() -> None: + """The core yields Dispatch, captures ids, and substitutes them -- no tmux. + + A hand-rolled driver feeds fake ids back, proving the resolution logic is a + pure generator independent of any I/O (just like the sync/async drivers). + """ + plan = ForwardPlan(PaneTarget("%1")) + left, right = plan.split(horizontal=True), plan.split() + left.do(_mark("L")) + right.do(_mark("R")) + + gen = drive(tuple(plan._steps)) + dispatched: list[tuple[str, ...]] = [] + fake_ids = iter(["%7", "%8"]) + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + dispatched.append(request.argv) + out = [next(fake_ids)] if request.captures is not None else [] + request = gen.send(_FakeResult(stdout=out)) + except StopIteration as stop: + resolved = stop.value + + assert dispatched == [ + # each creation dispatched alone with id capture (both split the seed %1): + ("split-window", "-t", "%1", "-h", "-P", "-F", "#{pane_id}"), + ("split-window", "-t", "%1", "-v", "-P", "-F", "#{pane_id}"), + # downstream commands fold into one trailing \; chain, ids substituted: + ( + "set-option", + "-t", + "%7", + "-p", + "@m", + "L", + ";", + "set-option", + "-t", + "%8", + "-p", + "@m", + "R", + ), + ] + assert resolved.bindings == {0: "%7", 1: "%8"} + + +def _mark_of(session: Session, pane_id: str) -> list[str]: + return session.server.cmd("display-message", "-p", "-t", pane_id, "#{@m}").stdout + + +def test_multidispatch_two_handles_sync(session: Session) -> None: + """Live: two independent forward panes, each captured + decorated correctly.""" + window = session.new_window(window_name="resolve_sync") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + left, right = plan.split(horizontal=True), plan.split() + left.do(_mark("LEFT")) + right.do(_mark("RIGHT")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert set(resolved.bindings) == {0, 1} + assert resolved.bindings[0] != resolved.bindings[1] + window.refresh() + assert len(window.panes) == 3 # seed + two independent panes + assert _mark_of(session, resolved.bindings[0]) == ["LEFT"] + assert _mark_of(session, resolved.bindings[1]) == ["RIGHT"] + + +@pytest.mark.asyncio +async def test_multidispatch_two_handles_async(session: Session) -> None: + """Live async: the same resolution core, driven with await.""" + window = session.new_window(window_name="resolve_async") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(horizontal=True).do(_mark("AL")) + plan.split().do(_mark("AR")) + + resolved = await plan.run_resolving_async(AsyncSessionPlanExecutor(session)) + + window.refresh() + assert len(window.panes) == 3 + assert _mark_of(session, resolved.bindings[0]) == ["AL"] + assert _mark_of(session, resolved.bindings[1]) == ["AR"] + + +def test_multidispatch_from_query_seed(session: Session) -> None: + """Live: seed the plan from the first row of a query (resolved at run).""" + window = session.new_window(window_name="resolve_seed") + assert window.active_pane is not None + + plan = ForwardPlan.from_query(panes().filter(active=True)) + plan.split(horizontal=True).do(_mark("A")) + plan.split().do(_mark("B")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert _mark_of(session, resolved.bindings[0]) == ["A"] + assert _mark_of(session, resolved.bindings[1]) == ["B"] + + +def test_handle_scope_guard() -> None: + """A creation verb on the wrong tmux scope fails fast at build time (pure).""" + plan = ForwardPlan() + sess = plan.new_session(name="g") + win = sess.new_window(name="w") + pane = win.split() # window -> split is allowed + + with pytest.raises(TypeError): + sess.split() # a session has no single pane to split + with pytest.raises(TypeError): + win.new_window() # only a session creates windows + with pytest.raises(TypeError): + pane.new_window() # ditto for a pane + + +@pytest.mark.parametrize( + "case", + FORWARD_DECORATE_CHAINABILITY_CASES, + ids=[case.test_id for case in FORWARD_DECORATE_CHAINABILITY_CASES], +) +def test_forward_handle_do_enforces_chainability( + case: ForwardDecorateChainabilityCase, +) -> None: + """Forward decorations cannot bypass the chainability contract.""" + plan = ForwardPlan(PaneTarget("%1")) + handle = plan.split() + + with pytest.raises(ChainabilityError, match=case.match): + handle.do(case.build) + + +def test_multidispatch_session_window_pane(session: Session) -> None: + """Live: one plan builds a session, two independent windows, a split in each. + + Five creations resolve over five dispatches -- a session (``$N``), two + windows captured via ``new-window -t $N:`` (``@M``), and a split inside each + window (``%K``) -- proving the builder spans all three tmux scopes. + """ + server = session.server + name = "cc_md_swp" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) + w_left = sess.new_window(name="left") + w_right = sess.new_window(name="right") + w_left.split(horizontal=True).do(_mark("WL")) + w_right.split().do(_mark("WR")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert set(resolved.bindings) == {0, 1, 2, 3, 4} + assert resolved.bindings[0].startswith("$") # session + assert resolved.bindings[1].startswith("@") # left window + assert resolved.bindings[2].startswith("@") # right window + assert resolved.bindings[3].startswith("%") # pane split into left + assert resolved.bindings[4].startswith("%") # pane split into right + + created = next(s for s in server.sessions if s.session_name == name) + wins = {w.window_name: w for w in created.windows} + assert {"left", "right"} <= set(wins) + wins["left"].refresh() + wins["right"].refresh() + assert len(wins["left"].panes) == 2 # each window's pane was split + assert len(wins["right"].panes) == 2 + assert _mark_of(session, resolved.bindings[3]) == ["WL"] + assert _mark_of(session, resolved.bindings[4]) == ["WR"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +@pytest.mark.asyncio +async def test_multidispatch_session_window_async(session: Session) -> None: + """Live async: the same session/window/pane span, driven with await.""" + server = session.server + name = "cc_md_swp_async" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) + w_a = sess.new_window(name="a") + w_b = sess.new_window(name="b") + w_a.split().do(_mark("A")) + w_b.split().do(_mark("B")) + + resolved = await plan.run_resolving_async(AsyncSessionPlanExecutor(session)) + + assert resolved.bindings[0].startswith("$") + assert _mark_of(session, resolved.bindings[3]) == ["A"] + assert _mark_of(session, resolved.bindings[4]) == ["B"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_creation_options_render() -> None: + """split/new_session/new_window render -c/-e/size onto the create argv.""" + plan = ForwardPlan(PaneTarget("%1")) + plan.split(start_directory="/tmp", environment={"FOO": "bar"}) + assert plan._steps[0].call.args == ("-v", "-c/tmp", "-eFOO=bar") + + plan2 = ForwardPlan() + sess = plan2.new_session( + name="x", start_directory="/tmp", environment={"A": "1"}, width=200, height=50 + ) + sess.new_window( + name="w", start_directory="/srv", environment={"B": "2"}, window_shell="zsh" + ) + assert plan2._steps[0].call.args == ( + "-d", + "-s", + "x", + "-c/tmp", + "-eA=1", + "-x", + "200", + "-y", + "50", + ) + assert plan2._steps[1].call.args == ("-n", "w", "-c/srv", "-eB=2", "zsh") + + +def test_creation_start_directory_live( + session: Session, tmp_path: pathlib.Path +) -> None: + """Live: a forward split honours start_directory.""" + window = session.new_window(window_name="cc_cwd") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(start_directory=str(tmp_path)) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + cwd = session.server.cmd( + "display-message", "-p", "-t", resolved.bindings[0], "#{pane_current_path}" + ).stdout + assert cwd + assert pathlib.Path(cwd[0]).resolve() == tmp_path.resolve() + + +def test_typed_verbs_in_forward_plan(session: Session) -> None: + """Live: the new typed bound-namespace verbs work as decorates in a plan.""" + window = session.new_window(window_name="cc_verbs") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split().do(lambda h: h.cmd.set_option("@cc_v", "ok")) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + val = session.server.cmd( + "display-message", "-p", "-t", resolved.bindings[0], "#{@cc_v}" + ).stdout + assert val == ["ok"] + + +def test_server_plan_runner_creates_session_from_scratch(session: Session) -> None: + """ServerPlanRunner runs a create-from-scratch plan without a seed session.""" + server = session.server + name = "cc_server_runner" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) # slot 0 + sess.new_window(name="w").split().do(_mark("SR")) # slots 1, 2 + resolved = plan.run_resolving(ServerPlanRunner(server)) + + assert resolved.session(0, server).session_name == name + assert _mark_of(session, resolved.bindings[2]) == ["SR"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_resolved_maps_slots_to_live_objects(session: Session) -> None: + """Resolved.pane/window/session(slot, server) return the created libtmux objects.""" + server = session.server + name = "cc_resolved_objs" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) # slot 0 (session) + win = sess.new_window(name="w") # slot 1 (window) + win.split() # slot 2 (pane) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert resolved.session(0, server).session_id == resolved.bindings[0] + assert resolved.window(1, server).window_id == resolved.bindings[1] + assert resolved.pane(2, server).pane_id == resolved.bindings[2] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_preserve_mark_skips_marked_fold() -> None: + """allow_marked=False forces multi-dispatch -- the mark is untouched (pure).""" + plan = ForwardPlan(PaneTarget("%1")) + plan.split().do(lambda h: h.cmd.raw("set-option", "-p", "@m", "X")) + + gen = drive(tuple(plan._steps), allow_marked=False) + argvs: list[tuple[str, ...]] = [] + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + argvs.append(request.argv) + request = gen.send(_FakeResult(stdout=["%9"])) + except StopIteration: + pass + + flat = [tok for argv in argvs for tok in argv] + assert "select-pane" not in flat # mark register untouched + assert len(argvs) == 2 # multi-dispatch: create + decorate + + +def test_marked_fold_preserves_pre_create_decorate_order() -> None: + """Seed decorations authored before a split must run before the split.""" + plan = ForwardPlan(PaneTarget("%1")) + plan.seed.do(_mark("seed")) + plan.split().do(_mark("child")) + + gen = drive(tuple(plan._steps)) + argvs: list[tuple[str, ...]] = [] + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + argvs.append(request.argv) + stdout = ["%9"] if request.captures is not None else [] + request = gen.send(_FakeResult(stdout=stdout)) + except StopIteration: + pass + + assert argvs == [ + ("set-option", "-t", "%1", "-p", "@m", "seed"), + ("split-window", "-t", "%1", "-v", "-P", "-F", "#{pane_id}"), + ("set-option", "-t", "%9", "-p", "@m", "child"), + ] + + +def test_preserve_mark_keeps_user_mark(session: Session) -> None: + """Live: preserve_mark=True leaves a pre-existing user mark intact.""" + window = session.new_window(window_name="cc_mark") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + session.server.cmd("select-pane", "-m", "-t", seed.pane_id) # user marks a pane + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split().do(_mark("M")) + plan.run_resolving(SessionPlanExecutor(session), preserve_mark=True) + + marked = session.server.cmd( + "display-message", "-p", "-t", seed.pane_id, "#{pane_marked}" + ).stdout + assert marked == ["1"] # default marked-fold would have cleared it + + +def test_initial_handles_require_session() -> None: + """initial_pane/initial_window are only available on a session handle (pure).""" + pane = ForwardPlan(PaneTarget("%1")).split() # a pane handle + with pytest.raises(TypeError): + _ = pane.initial_pane + with pytest.raises(TypeError): + _ = pane.initial_window + + +def test_initial_pane_builds_onto_default_window(session: Session) -> None: + """Live: a new session's default window is built onto, not orphaned.""" + server = session.server + name = "cc_initial" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) + sess.initial_pane.do(lambda h: h.window.rename("main")) + sess.initial_pane.split().do(_mark("IP")) + resolved = plan.run_resolving(ServerPlanRunner(server)) + + created = resolved.session(0, server) + created.refresh() + assert len(created.windows) == 1 # no orphan window + main = created.windows[0] + assert main.window_name == "main" + main.refresh() + assert len(main.panes) == 2 # default pane was split + assert _mark_of(session, resolved.bindings[1]) == ["IP"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_seed_from_existing_scopes_render() -> None: + """from_session/from_window/from_pane build creates targeting the seed id.""" + splan = ForwardPlan.from_session("$0") + splan.new_window(name="w") + assert splan._steps[0].call.argv() == ("new-window", "-t", "$0:", "-n", "w") + + wplan = ForwardPlan.from_window("@1") + wplan.split(horizontal=True) + assert wplan._steps[0].call.argv() == ("split-window", "-t", "@1", "-h") + + pplan = ForwardPlan.from_pane("%5") + pplan.split() + assert pplan._steps[0].call.argv() == ("split-window", "-t", "%5", "-v") + + +def test_seed_handle_decorates_existing_object() -> None: + """plan.seed exposes the pre-existing seed as a decoratable handle.""" + plan = ForwardPlan.from_pane("%1") + assert plan.seed.cmd.send_keys("clear", enter=True).argv() == ( + "send-keys", + "-t", + "%1", + "clear", + "Enter", + ) + # a query-seeded plan has no concrete seed to hand back: + qplan = ForwardPlan.from_query(panes().filter(active=True)) + with pytest.raises(ValueError, match="no concrete seed"): + _ = qplan.seed + + +def test_seed_from_live_session_adds_window(session: Session) -> None: + """Live: from_session(live) adds a window + split to the existing session.""" + n_before = len(session.windows) + + plan = ForwardPlan.from_session(session) + plan.new_window(name="cc_seeded").split().do(_mark("SEED")) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + session.refresh() + assert len(session.windows) == n_before + 1 + new_win = next(w for w in session.windows if w.window_name == "cc_seeded") + new_win.refresh() + assert len(new_win.panes) == 2 # the window's pane was split + assert _mark_of(session, resolved.bindings[1]) == ["SEED"] # slot 1 = split pane + + +def test_forward_dispatch_error_on_failed_create() -> None: + """A failed creation dispatch raises ForwardDispatchError, not IndexError.""" + plan = ForwardPlan(PaneTarget("%1")) + plan.split() + + gen = drive(tuple(plan._steps)) + next(gen) # the split's capturing dispatch + with pytest.raises(ForwardDispatchError) as excinfo: + gen.send(_FakeResult(stdout=[], stderr=["no space for new pane"], returncode=1)) + + assert "no space for new pane" in str(excinfo.value) + assert excinfo.value.argv[0] == "split-window" + + +def test_forward_dispatch_error_live(session: Session) -> None: + """Live: splitting against a bogus target fails loudly with the tmux error.""" + plan = ForwardPlan(PaneTarget("%nonexistent999")) + plan.split() + with pytest.raises(ForwardDispatchError): + plan.run_resolving(SessionPlanExecutor(session)) + + +def test_marked_eligible_classifies_plan_shapes() -> None: + """The analyzer picks single-dispatch only for a lone pane creation.""" + one_pane = ForwardPlan(PaneTarget("%1")) + one_pane.split().do(_mark("x")) + assert _marked_eligible(tuple(one_pane._steps)) is not None + + two_panes = ForwardPlan(PaneTarget("%1")) + two_panes.split() + two_panes.split() + assert _marked_eligible(tuple(two_panes._steps)) is None # one mark slot only + + one_session = ForwardPlan() + one_session.new_session(name="x") + # a detached session has no active pane to mark: + assert _marked_eligible(tuple(one_session._steps)) is None + + +def test_marked_single_dispatch_folds_one_handle() -> None: + """A lone pane handle resolves in ONE ``{marked}`` invocation (pure, no tmux). + + The split captures its id, marks the new (active) pane, the decorate + addresses it via ``{marked}``, and the mark is cleared -- all one dispatch. + """ + plan = ForwardPlan(PaneTarget("%1")) + plan.split(horizontal=True).do(_mark("L")) + + gen = drive(tuple(plan._steps)) + dispatched: list[tuple[str, ...]] = [] + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + dispatched.append(request.argv) + out = ["%7"] if request.captures is not None else [] + request = gen.send(_FakeResult(stdout=out)) + except StopIteration as stop: + resolved = stop.value + + assert dispatched == [ + ( + "split-window", + "-t", + "%1", + "-h", + "-P", + "-F", + "#{pane_id}", + ";", + "select-pane", + "-m", + ";", + "set-option", + "-t", + "{marked}", + "-p", + "@m", + "L", + ";", + "select-pane", + "-M", + ), + ] + assert len(dispatched) == 1 # one invocation, not create + decorate + assert resolved.bindings == {0: "%7"} + + +def test_marked_single_dispatch_live(session: Session) -> None: + """Live: a lone forward pane resolves in one dispatch and leaks no mark.""" + window = session.new_window(window_name="marked_solo") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(horizontal=True).do(_mark("SOLO")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert len(resolved.results) == 1 # single invocation, not create + decorate + new_pane_id = resolved.bindings[0] + window.refresh() + assert len(window.panes) == 2 + assert _mark_of(session, new_pane_id) == ["SOLO"] + # the server-wide mark register is cleared afterward, not leaked: + marked = session.server.cmd( + "display-message", "-p", "-t", new_pane_id, "#{pane_marked}" + ).stdout + assert marked == ["0"] + + +def test_marked_single_dispatch_failure_clears_mark(session: Session) -> None: + """Live: a failed marked dispatch still clears tmux's mark register.""" + window = session.new_window(window_name="marked_fail") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + assert window.window_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(horizontal=True).do(lambda h: h.window.select_layout("not-a-layout")) + + with pytest.raises(ForwardDispatchError): + plan.run_resolving(SessionPlanExecutor(session)) + + marked = session.server.cmd( + "list-panes", "-t", window.window_id, "-F", "#{pane_id}:#{pane_marked}" + ).stdout + assert marked + assert all(row.endswith(":0") for row in marked) diff --git a/tests/test/test_retry.py b/tests/test/test_retry.py index c2a0f9255..61b5134a4 100644 --- a/tests/test/test_retry.py +++ b/tests/test/test_retry.py @@ -11,13 +11,14 @@ def test_retry_three_times() -> None: - """Test retry_until().""" - ini = time() + """retry_until retries until the callable succeeds.""" + calls = 0 value = 0 def call_me_three_times() -> bool: - nonlocal value - sleep(0.3) # Sleep for 0.3 seconds to simulate work + nonlocal value, calls + calls += 1 + sleep(0.3) # simulate work if value == 2: return True @@ -25,73 +26,76 @@ def call_me_three_times() -> bool: value += 1 return False - retry_until(call_me_three_times, 1) - - end = time() - - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + # Generous budget so all three calls fit even under load; assert on behavior + # (call count + success), not wall-clock, to stay deterministic. + assert retry_until(call_me_three_times, 5) is True + assert calls == 3 def test_function_times_out() -> None: - """Test time outs with retry_until().""" + """retry_until raises WaitTimeout after exhausting its budget.""" ini = time() + calls = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) + nonlocal calls + calls += 1 + sleep(0.1) # simulate work return False with pytest.raises(exc.WaitTimeout): retry_until(never_true, 1) - end = time() - - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + # It retried for the full budget before timing out. The lower bound is + # deterministic (retry_until only times out once elapsed >= the budget); + # no fragile upper bound that load can blow past. + assert (time() - ini) >= 0.9 + assert calls > 1 def test_function_times_out_no_raise() -> None: - """Tests retry_until() with exception raising disabled.""" + """retry_until returns instead of raising when raises=False.""" ini = time() + calls = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) + nonlocal calls + calls += 1 + sleep(0.1) # simulate work return False retry_until(never_true, 1, raises=False) - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert (time() - ini) >= 0.9 + assert calls > 1 def test_function_times_out_no_raise_assert() -> None: - """Tests retry_until() with exception raising disabled, returning False.""" + """retry_until returns False on timeout when raises=False.""" ini = time() + calls = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) + nonlocal calls + calls += 1 + sleep(0.1) # simulate work return False assert not retry_until(never_true, 1, raises=False) - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert (time() - ini) >= 0.9 + assert calls > 1 def test_retry_three_times_no_raise_assert() -> None: - """Tests retry_until() with exception raising disabled, with closure variable.""" - ini = time() + """retry_until returns True on success even with raises=False.""" + calls = 0 value = 0 def call_me_three_times() -> bool: - nonlocal value - sleep( - 0.3, - ) # Sleep for 0.3 seconds to simulate work (called 3 times in ~0.9 seconds) + nonlocal value, calls + calls += 1 + sleep(0.3) # simulate work if value == 2: return True @@ -99,7 +103,6 @@ def call_me_three_times() -> bool: value += 1 return False - assert retry_until(call_me_three_times, 1, raises=False) - - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + # Behavior-based, generous budget: deterministic even under load. + assert retry_until(call_me_three_times, 5, raises=False) is True + assert calls == 3 diff --git a/tests/test_pane.py b/tests/test_pane.py index 416032ce6..7b887e00c 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -17,6 +17,7 @@ if t.TYPE_CHECKING: from libtmux._internal.types import StrPath from libtmux.pane import Pane + from libtmux.server import Server from libtmux.session import Session logger = logging.getLogger(__name__) @@ -1335,6 +1336,27 @@ def test_break_pane_basic(session: Session) -> None: assert new_window.window_id is not None +def test_break_pane_targets_owning_session(server: Server) -> None: + """``break_pane`` creates the new window in the pane's own session. + + Regression for the bundled targeting fix: a bare break-pane destination + resolves against the server's *current* session, which can differ from the + pane's session. A second session created afterwards becomes the current one, + so the broken-out window must still land in the owning session. + """ + owning = server.new_session(session_name="cc_break_owner") + server.new_session(session_name="cc_break_other") + + window = owning.new_window(window_name="to_break") + pane = window.active_pane + assert pane is not None + new_pane = pane.split(shell="sleep 1m") + + new_window = new_pane.break_pane() + + assert new_window.session_id == owning.session_id + + def test_break_pane_with_name(session: Session) -> None: """Test Pane.break_pane() with window_name.""" window = session.new_window(window_name="test_break_name") diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 23acbcfe9..eebc505f8 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -76,7 +76,10 @@ def test_repo_git_remote_checkout( ) # Test - result = pytester.runpytest(str(first_test_filename)) + # Inner pytester runs are isolated and don't read this project's + # asyncio_default_fixture_loop_scope, so pytest-asyncio would emit its + # loop-scope deprecation. Disable the plugin for these synchronous runs. + result = pytester.runpytest(str(first_test_filename), "-p", "no:asyncio") result.assert_outcomes(passed=1) diff --git a/tests/test_session.py b/tests/test_session.py index f7d95e4cc..188f3be85 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -103,6 +103,28 @@ def test_active_pane(session: Session) -> None: assert isinstance(session.active_pane, Pane) +def test_kill_window_targets_owning_session(server: Server) -> None: + """``kill_window`` scopes a bare window name to its owning session. + + Regression for the bundled targeting fix: a bare window name resolves + against the server's current session, so two sessions holding a window of + the same name could cross-target. Killing the owning session's window must + leave the other session's same-named window untouched. + """ + owning = server.new_session(session_name="cc_killwin_owner") + other = server.new_session(session_name="cc_killwin_other") + + owning.new_window(window_name="dup") + other.new_window(window_name="dup") + + owning.kill_window("dup") + + owning.refresh() + other.refresh() + assert "dup" not in [w.window_name for w in owning.windows] + assert "dup" in [w.window_name for w in other.windows] + + def test_session_rename(session: Session) -> None: """Session.rename_session renames session.""" session_name = session.session_name diff --git a/uv.lock b/uv.lock index 1369940a5..116261d2e 100644 --- a/uv.lock +++ b/uv.lock @@ -53,57 +53,56 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.1" +version = "4.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, ] [[package]] name = "ast-serialize" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, - { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, - { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, - { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, - { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, - { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, - { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, - { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, - { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, - { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, - { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, - { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, - { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, - { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, - { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, - { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, - { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, - { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, - { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] [[package]] @@ -115,6 +114,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -244,14 +252,14 @@ wheels = [ [[package]] name = "click" -version = "8.4.2" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -519,89 +527,87 @@ wheels = [ [[package]] name = "librt" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/66/c9d88366893b4b0df6b5375c27ebc9f14c43419d9e244b493be20e85bc74/librt-0.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fe3547407bbce45c09885591f90168325c5a31a6795b9a13f6b9ff3d25093d93", size = 144398, upload-time = "2026-06-30T16:12:03.947Z" }, - { url = "https://files.pythonhosted.org/packages/bd/f2/9be1c6da204701163ec3aaedbf893d2f656b363d8fa302af536ce6471eb4/librt-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5925eca673207204a3adca040a91bdd3738fc7ba48da647ccd55732692a35736", size = 148924, upload-time = "2026-06-30T16:12:05.583Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f3/256824ee27649c6e0a693db25d391f97b43b52364f8efb466014a564bbc7/librt-0.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f9ef097a7711465a204454c69658bbb6b2a6be9bdef0eeeba9a042016d00688", size = 479654, upload-time = "2026-06-30T16:12:07.175Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3f/f4adbb3f293a04bd3dc2eb91d814f5b1e221e6b4522585696ba6901a0b9a/librt-0.12.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57abc8b65edf1a8e80e5472c81c108a7527202e5febfda9e00a684dbaeae534e", size = 472318, upload-time = "2026-06-30T16:12:08.758Z" }, - { url = "https://files.pythonhosted.org/packages/9d/b5/362c93f7b43d4ef84a3d5f156c8d4eeddb22badcf5529a1281c387abbbd7/librt-0.12.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e6f53732a8ae5012a3b6ae092da2933be74ec4169d16038f4af87a0019afea", size = 501555, upload-time = "2026-06-30T16:12:10.623Z" }, - { url = "https://files.pythonhosted.org/packages/24/1d/2d6abf059c3a4b88a6668e7bb81af332b14463028ac8f2b08a1212eb1ebc/librt-0.12.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:edb5f06cdb38d6ef9fd7ae06d62962d65c881b5f965d5e8a6c53e59c15ae4338", size = 494118, upload-time = "2026-06-30T16:12:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/39/c1/f91f3094be2c76361d88aca613d8b7586d15b6026714d59d2e3dc0e35f44/librt-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1473ef42263dfee7553a5c460f11730a4409acf0d52629b284eb1e6b13eb460a", size = 516318, upload-time = "2026-06-30T16:12:14.192Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e2/5211af94252458cbed7a6250163dff9c5a84aec29609121c828375a3b319/librt-0.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1d6f69a06295fb6ad8dcf92b4b2d15d211842005e86eedce64d88e0633592f58", size = 522294, upload-time = "2026-06-30T16:12:15.879Z" }, - { url = "https://files.pythonhosted.org/packages/90/9b/de31f5b9fdf7fa3699c4bbbecf82ebd52013d5d6b500b70b07b0ebacbd51/librt-0.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3275d0270cd07ca9c2e140ae4da34e24a0350e98c6e3815dce96ead67cf0487d", size = 502494, upload-time = "2026-06-30T16:12:17.394Z" }, - { url = "https://files.pythonhosted.org/packages/b1/48/22c18dff89f3900dddb3e470e6f7febcda37ff3667b73097a848c9a608b2/librt-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a4834462ec68613024d063c7efe9b188e350d40fda9ba937372039883d2a8051", size = 543422, upload-time = "2026-06-30T16:12:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/52/7b/74691b4b55944227245fffef063714e3ab9707ab1111eb0068512b428c7c/librt-0.12.0-cp310-cp310-win32.whl", hash = "sha256:bcf9b55ac089e8cf201d2146833e1097812c15dcea61911e84d6a2904cf78893", size = 97642, upload-time = "2026-06-30T16:12:20.386Z" }, - { url = "https://files.pythonhosted.org/packages/c5/dc/7f8fa369a1f7cc9b090fecd373659ada0e9bab1ae4a3ac9f163eabd04977/librt-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0a122002f7e0d5c93e84465c4b3fe86621402b7b92f1e2bc0784ebe67793112", size = 117583, upload-time = "2026-06-30T16:12:21.829Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ab/628490f42d1eba82f3c7e5821aa62013e6df7f525b7a9e92c048f8d1cc1c/librt-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f13c1e8563102c2b17581cf37fcb2c6dae7ad485ccea93ae46258998c25f9a1", size = 143821, upload-time = "2026-06-30T16:12:23.248Z" }, - { url = "https://files.pythonhosted.org/packages/38/5f/793e8b6f4b6ac16e7d7198478c0af3670606fbb535c768d5f3e954781423/librt-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1ddff067610a122387024c4df527493b909d41e54a6e5b2d0e6c1041d6dfa09", size = 148442, upload-time = "2026-06-30T16:12:24.582Z" }, - { url = "https://files.pythonhosted.org/packages/ad/92/c780fe37a9e0982f3bd8fd9a631d6b95d09a5a7201c6c50366ce843b7e42/librt-0.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8dc7ebb5f3eec062398e9d0ef1938acd21b589e74286c4a8906d0183318d91b", size = 478276, upload-time = "2026-06-30T16:12:26.101Z" }, - { url = "https://files.pythonhosted.org/packages/41/bb/226d444bc20d7dff4a19ec6c1ff2c13a76385eebddb59c9c00c923b67536/librt-0.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:198de569ea9d5f6f33808f1c00cc3db9de62bf4d6deafa3b052bd08255083038", size = 472337, upload-time = "2026-06-30T16:12:27.83Z" }, - { url = "https://files.pythonhosted.org/packages/12/79/98ac0840ee90a75d4e1155c79062860b12ccca508587ff2119fc086965f2/librt-0.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e958678a8bca56016aedc891b391c0e0813ea382a874b54a2c1b313c1d232720", size = 502087, upload-time = "2026-06-30T16:12:29.443Z" }, - { url = "https://files.pythonhosted.org/packages/6f/72/a6b1a0d080606a7f5f646b79a1496f21d709f8563877759ace9ce5adad73/librt-0.12.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575a6eca68c8437ed4a8e0f534e31d74b562ba1049a0ee4b5f09e114bcc21be1", size = 493202, upload-time = "2026-06-30T16:12:31.077Z" }, - { url = "https://files.pythonhosted.org/packages/69/cf/e1b036b45f2fc272205ee18bf272b47e8d684bf1a75af26db440c7504359/librt-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:86f241c50dc9e9a3f0db6dbb37a607c8205aa87b920802dabbd50b70d40f6939", size = 514139, upload-time = "2026-06-30T16:12:33.032Z" }, - { url = "https://files.pythonhosted.org/packages/40/34/b193b3e6985469a2f8afa86c90012329c86480b6ff4f2e4bd7b5b937e134/librt-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:113417b934fbf38220a9c7fe94578cefbe7dbb047adcb75aa197905af2b13724", size = 519486, upload-time = "2026-06-30T16:12:34.996Z" }, - { url = "https://files.pythonhosted.org/packages/31/9e/7de4947b1695f247c813f833e3c1e7b77b52e52a7dba2c35411cf806b58e/librt-0.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:762f17c0eb6b5d74e269126996cea8a89e35ab6464c5151619163abcd8623ae2", size = 499609, upload-time = "2026-06-30T16:12:36.663Z" }, - { url = "https://files.pythonhosted.org/packages/59/11/f3730e04e758b1fbf215359062ad2d5b6bd0b0ab5ac46b1c140628795be7/librt-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa93b3bd7f7588c628f6e9bf66485d3467fd9a1ccdb8975b770178f39f35697", size = 542205, upload-time = "2026-06-30T16:12:38.56Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8f/710453617eabe20e18433864f335534c8aff63fbc68d8cd9dbc70a3d08f6/librt-0.12.0-cp311-cp311-win32.whl", hash = "sha256:aaa04b44d4fe86d824616b1f9c13e34c7c01ec0c96dd2abc4f59423696f788e2", size = 98067, upload-time = "2026-06-30T16:12:40.102Z" }, - { url = "https://files.pythonhosted.org/packages/42/53/401bff50a56e95daf151d911c99adf5732af2190e8f4d11886c9a229103c/librt-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:9aaeeddb8e7e4ae3bb9f944e0e618418cb91c0071d5ddbfcc3584b3cf59d39f0", size = 118346, upload-time = "2026-06-30T16:12:41.388Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9a/a3a9078fe88bfc2d2d99dcf1c18593938ae830089cf84c3b2532a6c49d63/librt-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:18a2402fa3123ab76ecca670e6fb33038fde7c1e91181b885226ec4d30af2c2c", size = 104760, upload-time = "2026-06-30T16:12:43.112Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, - { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, - { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, - { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, - { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, - { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, - { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, - { url = "https://files.pythonhosted.org/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", size = 146808, upload-time = "2026-06-30T16:13:05.417Z" }, - { url = "https://files.pythonhosted.org/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", size = 145503, upload-time = "2026-06-30T16:13:07.023Z" }, - { url = "https://files.pythonhosted.org/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", size = 488421, upload-time = "2026-06-30T16:13:08.492Z" }, - { url = "https://files.pythonhosted.org/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", size = 483488, upload-time = "2026-06-30T16:13:10.169Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", size = 518428, upload-time = "2026-06-30T16:13:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", size = 509744, upload-time = "2026-06-30T16:13:13.217Z" }, - { url = "https://files.pythonhosted.org/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", size = 527749, upload-time = "2026-06-30T16:13:15.277Z" }, - { url = "https://files.pythonhosted.org/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", size = 532582, upload-time = "2026-06-30T16:13:17.074Z" }, - { url = "https://files.pythonhosted.org/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", size = 522235, upload-time = "2026-06-30T16:13:18.823Z" }, - { url = "https://files.pythonhosted.org/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", size = 559055, upload-time = "2026-06-30T16:13:20.509Z" }, - { url = "https://files.pythonhosted.org/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", size = 79809, upload-time = "2026-06-30T16:13:22.401Z" }, - { url = "https://files.pythonhosted.org/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", size = 99308, upload-time = "2026-06-30T16:13:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", size = 119438, upload-time = "2026-06-30T16:13:25.188Z" }, - { url = "https://files.pythonhosted.org/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", size = 105118, upload-time = "2026-06-30T16:13:26.533Z" }, - { url = "https://files.pythonhosted.org/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", size = 145579, upload-time = "2026-06-30T16:13:27.909Z" }, - { url = "https://files.pythonhosted.org/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", size = 150139, upload-time = "2026-06-30T16:13:29.357Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", size = 480457, upload-time = "2026-06-30T16:13:30.78Z" }, - { url = "https://files.pythonhosted.org/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", size = 479002, upload-time = "2026-06-30T16:13:32.398Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", size = 510527, upload-time = "2026-06-30T16:13:34.615Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", size = 500988, upload-time = "2026-06-30T16:13:36.408Z" }, - { url = "https://files.pythonhosted.org/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", size = 519318, upload-time = "2026-06-30T16:13:37.883Z" }, - { url = "https://files.pythonhosted.org/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", size = 527127, upload-time = "2026-06-30T16:13:39.682Z" }, - { url = "https://files.pythonhosted.org/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", size = 509766, upload-time = "2026-06-30T16:13:41.266Z" }, - { url = "https://files.pythonhosted.org/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", size = 552043, upload-time = "2026-06-30T16:13:43.197Z" }, - { url = "https://files.pythonhosted.org/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", size = 79472, upload-time = "2026-06-30T16:13:44.64Z" }, - { url = "https://files.pythonhosted.org/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", size = 94246, upload-time = "2026-06-30T16:13:45.962Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", size = 114951, upload-time = "2026-06-30T16:13:47.279Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", size = 100562, upload-time = "2026-06-30T16:13:48.699Z" }, - { url = "https://files.pythonhosted.org/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", size = 153897, upload-time = "2026-06-30T16:13:50.017Z" }, - { url = "https://files.pythonhosted.org/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", size = 156391, upload-time = "2026-06-30T16:13:51.462Z" }, - { url = "https://files.pythonhosted.org/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", size = 564151, upload-time = "2026-06-30T16:13:53.146Z" }, - { url = "https://files.pythonhosted.org/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", size = 546002, upload-time = "2026-06-30T16:13:54.665Z" }, - { url = "https://files.pythonhosted.org/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", size = 584204, upload-time = "2026-06-30T16:13:56.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", size = 573688, upload-time = "2026-06-30T16:13:58.1Z" }, - { url = "https://files.pythonhosted.org/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", size = 604719, upload-time = "2026-06-30T16:13:59.831Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", size = 598183, upload-time = "2026-06-30T16:14:01.457Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", size = 582559, upload-time = "2026-06-30T16:14:03.29Z" }, - { url = "https://files.pythonhosted.org/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", size = 626375, upload-time = "2026-06-30T16:14:04.957Z" }, - { url = "https://files.pythonhosted.org/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", size = 97752, upload-time = "2026-06-30T16:14:06.555Z" }, - { url = "https://files.pythonhosted.org/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", size = 119562, upload-time = "2026-06-30T16:14:07.908Z" }, - { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] @@ -622,6 +628,7 @@ dev = [ { name = "gp-sphinx" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -650,6 +657,7 @@ lint = [ testing = [ { name = "gp-libs" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, @@ -671,6 +679,7 @@ dev = [ { name = "gp-sphinx", specifier = "==0.0.1a32" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -697,6 +706,7 @@ lint = [ testing = [ { name = "gp-libs", specifier = ">=0.0.18" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, @@ -1016,6 +1026,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0" @@ -1183,27 +1207,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +version = "0.15.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/e6/15800dfde183a1a106594016c912b4c12d050a301989d1aca6cb63759fe8/ruff-0.15.19.tar.gz", hash = "sha256:edc27f7172a93b32b102687009d6a588508815072141543ae603a8b9b0823063", size = 4772071, upload-time = "2026-06-24T01:10:46.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/4c/9ded7626c39a0440c575bf69e2bf500d443388272c842662c59852ee7fcd/ruff-0.15.19-py3-none-linux_armv6l.whl", hash = "sha256:922d1eb283161564759bd49f507e91dc6112c15da8bd5b84ed714e086243cf86", size = 10950859, upload-time = "2026-06-24T01:10:38.491Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ef/c211505ece1d00ef493d58e54e3b6383c946a21e9874774eb531f2512cf3/ruff-0.15.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4d190d8f62a0b94aba8f721116538a9ee29b1e74d26650846ba9b99f0ae21c40", size = 11294529, upload-time = "2026-06-24T01:10:36.481Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/78d462e7d39968e58094dc57be7d09ffb14ce37da5b68ed70338a35a1f21/ruff-0.15.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a2c86ba6870dd415a9d9eb8be94d7924ebec6a26ffc7958ec7ca29d4bff967d", size = 10641416, upload-time = "2026-06-24T01:10:48.923Z" }, + { url = "https://files.pythonhosted.org/packages/76/c4/5cb66cfd1f865d5cca908b86c93ac785e7f572193d3c7426079ca6643e24/ruff-0.15.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b432bc087264aea70fd25ac198918b70bd9e2aa0db4297b0bb91bbfbbc63ce", size = 11015582, upload-time = "2026-06-24T01:10:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/51/9f/8ecfaec10cf5eecd28fbc00ff4fb867db90a1be54bf3d39ebf93f893cd52/ruff-0.15.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8530a09d03b3a8c994f8b559a7dcdabc690bcd3f78ef276c38c83166798ebf56", size = 10744059, upload-time = "2026-06-24T01:10:32.48Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/983249d04562bc2d590edd75f32455cdb473affb3ba4bc8d883e939c697d/ruff-0.15.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87bf21fb3875fe69f0eacc825411657e2e85589cce633c35c0adf1113649c62b", size = 11568461, upload-time = "2026-06-24T01:10:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/eb/39/bc7794f127b18f492a3b4ee82bba5a900c985ff13b72b46f46e3c171ba34/ruff-0.15.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9b229cb3ef56ecc2c1c8ebeca64b7a7740ccaef40a9eb097e78dde5a8560b83", size = 12429690, upload-time = "2026-06-24T01:10:40.638Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3b/0de6859e698ed11c8a49e765196c8d333599b6a546c0715df39b6ba1aa2e/ruff-0.15.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c754515be7b76afe6e7e62df7776709571bcfc1631183828afcf3bafa869e3", size = 11693067, upload-time = "2026-06-24T01:10:25.681Z" }, + { url = "https://files.pythonhosted.org/packages/89/3d/0b1f30f84bee9ae6ae8d349c2ba8b6f4b040966744efdd3acc804ae7c024/ruff-0.15.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a498f82e0f4d8904c4e0aea5139cdfac1f39d19a3c51d491292f63a36e83b2e", size = 11616911, upload-time = "2026-06-24T01:10:44.809Z" }, + { url = "https://files.pythonhosted.org/packages/4d/eb/c90bd3dfc12eed9032c2c1bfe05105b93a1b2c8bce555db6308315b853ce/ruff-0.15.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d48caa34488fb521fd0ef4aea2b0e8fe758298df044138f0d67b687a6a0d07ed", size = 11649343, upload-time = "2026-06-24T01:10:23.472Z" }, + { url = "https://files.pythonhosted.org/packages/82/91/01caa13602a2f12fae5edbe8caf78b3c1e6db1293132aee6959eecce095c/ruff-0.15.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4171b6613effa9363cd46dd4f75bd1827b6d1b946b5e278ed0c600d305379445", size = 10977610, upload-time = "2026-06-24T01:10:50.892Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/acb817922feab9ecbb3201377d4dbe7a25f1395e46545820061973f03468/ruff-0.15.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27c15b2a241dd4d995557949a094fe78b8ad99122a38ccae1595849bcc947b3f", size = 10744900, upload-time = "2026-06-24T01:10:42.726Z" }, + { url = "https://files.pythonhosted.org/packages/84/bc/5c8ca46b8a7a3f2b16cfbec88721d772b1c93912904e8f8c2e49470fea63/ruff-0.15.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ed03b7862d68f0a8771d50ee129980cbf1b113f96e250b73954bc292f689e0bb", size = 11293560, upload-time = "2026-06-24T01:10:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/81/e0/4a888cbe4d5523b3f77a2b1fa043f46cfeba1b32eac35dcfadee0578fa8a/ruff-0.15.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:08143f0685ae278b30727ea72e90c61e5bd9c31b91aac4f5bb989538f73d24b8", size = 11696533, upload-time = "2026-06-24T01:10:53.046Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/c34b2fcd79262a85161764a97aaca89c3e4f574340ab61430cefa2bdd2c1/ruff-0.15.19-py3-none-win32.whl", hash = "sha256:8f47f0f92952af2557212bb10cf3e695cd4cf28b2c6e42cdb18ec6c9ebfa19da", size = 10986299, upload-time = "2026-06-24T01:10:55.185Z" }, + { url = "https://files.pythonhosted.org/packages/22/e8/15fd23e02b2442b56b2026b455977bc3057aa34b26e6323d1e99e8531a9f/ruff-0.15.19-py3-none-win_amd64.whl", hash = "sha256:efeca47ee3f9d4a7162655a3b8e6ee4a878646044233978d4d2c1ff8cdd914f0", size = 12123473, upload-time = "2026-06-24T01:10:27.74Z" }, + { url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" }, ] [[package]]