Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
20fbc39
feat: Add psycopg-inspired async-first architecture for libtmux
tony Nov 9, 2025
ff78c90
common(cmd) AsyncTmuxCmd
tony Nov 9, 2025
4946b3a
Server,Session,Window,Pane: Add `.acmd`
tony Nov 9, 2025
b88bf1b
tests(async) Basic example
tony Nov 9, 2025
144f055
py(deps[dev]) Add `pytest-asyncio`
tony Nov 9, 2025
060e0a1
feat: Integrate asyncio branch with psycopg-style architecture
tony Nov 9, 2025
901cf8e
test: Add comprehensive async tests with guaranteed isolation
tony Nov 9, 2025
867cac1
py(deps) Bump `pytest-asyncio`
tony Nov 9, 2025
0e297a7
fix: Remove try/except around async fixtures
tony Nov 9, 2025
971278a
fix: Ensure server socket exists before testing error handling
tony Nov 9, 2025
aea00de
fix: Wrap async doctest in function for tmux_cmd_async
tony Nov 9, 2025
af6b238
refactor: Remove manual session cleanup from async tests
tony Nov 9, 2025
959326c
docs: Phase 1 - Make async support discoverable (Quick Wins)
tony Nov 9, 2025
c82410d
Make example scripts self-contained and copy-pasteable
tony Nov 9, 2025
a07dc13
Add comprehensive async documentation
tony Nov 9, 2025
92053fc
Expand common_async.py docstrings with dual pattern examples
tony Nov 9, 2025
70e74b9
fix: Correct async doctest examples for proper tmux isolation
tony Nov 9, 2025
5d7d198
refactor: Convert SKIP'd doctests to executable code blocks in common…
tony Nov 9, 2025
f43e838
test: Add verification tests for all docstring examples (Phase 2)
tony Nov 9, 2025
8342908
refactor: Convert common_async.py code blocks to executable doctests
tony Nov 9, 2025
1093a1b
refactor: Reorganize async tests into tests/asyncio/ directory
tony Nov 9, 2025
83d4dcc
test: Add comprehensive async environment variable tests
tony Nov 9, 2025
5a2a181
test: Add version checking edge case tests
tony Nov 9, 2025
77b0a03
feat: make tmux_cmd_async awaitable for typing
tony Nov 9, 2025
34d3a83
chore(rebase): merge duplicate pytest markers + drop redundant pane defs
tony May 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
452 changes: 232 additions & 220 deletions README.md

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@

from __future__ import annotations

import asyncio
import functools
import shutil
import typing as t

import pytest
import pytest_asyncio
from _pytest.doctest import DoctestItem

from libtmux._internal.control_mode import ControlMode
from libtmux.client import Client
from libtmux.common_async import get_version, tmux_cmd_async
from libtmux.pane import Pane
from libtmux.pytest_plugin import USING_ZSH
from libtmux.server import Server
Expand Down Expand Up @@ -59,6 +62,11 @@ def add_doctest_fixtures(
)
doctest_namespace["monkeypatch"] = request.getfixturevalue("monkeypatch")

# Add async support for async doctests
doctest_namespace["asyncio"] = asyncio
doctest_namespace["tmux_cmd_async"] = tmux_cmd_async
doctest_namespace["get_version"] = get_version


@pytest.fixture(autouse=True)
def set_home(
Expand All @@ -84,3 +92,51 @@ def setup_session(
"""Session-level test configuration for pytest."""
if USING_ZSH:
request.getfixturevalue("zshrc")


# Async test fixtures
# These require pytest-asyncio to be installed


@pytest_asyncio.fixture
async def async_server(server: Server):
"""Async wrapper for sync server fixture.

Provides async context while using proven sync server isolation.
Server has unique socket name from libtmux_test{random}.

The sync server fixture creates a Server with:
- Unique socket name: libtmux_test{8-random-chars}
- Automatic cleanup via request.addfinalizer
- Complete isolation from developer's tmux sessions

This wrapper just ensures we're in an async context.
All cleanup is handled by the parent sync fixture.
"""
await asyncio.sleep(0) # Ensure in async context
yield server
# Cleanup handled by sync fixture's finalizer


@pytest_asyncio.fixture
async def async_test_server(TestServer: t.Callable[..., Server]):
"""Async wrapper for TestServer factory fixture.

Returns factory that creates servers with unique sockets.
Each call to factory() creates new isolated server.

The sync TestServer fixture creates a factory that:
- Generates unique socket names per call
- Tracks all created servers
- Cleans up all servers via request.addfinalizer

Usage in async tests:
server1 = async_test_server() # Creates server with unique socket
server2 = async_test_server() # Creates another with different socket

This wrapper just ensures we're in an async context.
All cleanup is handled by the parent sync fixture.
"""
await asyncio.sleep(0) # Ensure in async context
yield TestServer
# Cleanup handled by TestServer's finalizer
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ isolated tmux fixtures:
:hidden:

quickstart
quickstart_async
topics/index
api/index
api/testing/index
Expand Down
54 changes: 18 additions & 36 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ from inside a live tmux session.

## Requirements

- [tmux] 3.2a or newer
- [tmux]
- [pip] - for this handbook's examples

[tmux]: https://tmux.github.io/
Expand Down Expand Up @@ -45,15 +45,10 @@ before general availability.
- [pipx]\:

```console
$ pipx install \
--suffix=@next \
--pip-args '\--pre' \
--force \
'libtmux'
$ pipx install --suffix=@next 'libtmux' --pip-args '\--pre' --force
// Usage: libtmux@next [command]
```

Usage: `libtmux@next [command]`

- [uv tool install][uv-tools]\:

```console
Expand Down Expand Up @@ -83,10 +78,7 @@ via trunk (can break easily):
- [pipx]\:

```console
$ pipx install \
--suffix=@master \
--force \
'libtmux @ git+https://github.com/tmux-python/libtmux.git@master'
$ pipx install --suffix=@master 'libtmux @ git+https://github.com/tmux-python/libtmux.git@master' --force
```

- [uv]\:
Expand Down Expand Up @@ -139,7 +131,7 @@ $ ptpython
```

```{module} libtmux
:no-index:

```

First, we can grab a {class}`~libtmux.Server`.
Expand Down Expand Up @@ -471,38 +463,27 @@ prevents adding it to the user's shell history. Omitting `enter=false` means the
default behavior (sending the command) is done, without needing to use
{meth}`pane.enter() <libtmux.Pane.enter>` after.

## Working with options
## Examples

libtmux provides a unified API for managing tmux options across
{class}`~libtmux.Server`, {class}`~libtmux.Session`,
{class}`~libtmux.Window`, and {class}`~libtmux.Pane` objects.

### Getting options

```python
>>> server.show_option('buffer-limit')
50

>>> window.show_options() # doctest: +ELLIPSIS
{...}
```

### Setting options
Want to see more? Check out our example scripts:

```python
>>> window.set_option('automatic-rename', False) # doctest: +ELLIPSIS
Window(@... ...)
- **[examples/async_demo.py]** - Async command execution with performance benchmarks
- **[examples/hybrid_async_demo.py]** - Both sync and async patterns working together
- **[More examples]** - Full examples directory on GitHub

>>> window.show_option('automatic-rename')
False
For async-specific guides, see:

>>> window.unset_option('automatic-rename') # doctest: +ELLIPSIS
Window(@... ...)
```
- {doc}`/quickstart_async` - Async quickstart tutorial
- {doc}`/topics/async_programming` - Comprehensive async guide
- {doc}`/api/common_async` - Async API reference

:::{seealso}
See {ref}`options-and-hooks` for more details on options and hooks.
:::
[examples/async_demo.py]: https://github.com/tmux-python/libtmux/blob/master/examples/async_demo.py
[examples/hybrid_async_demo.py]: https://github.com/tmux-python/libtmux/blob/master/examples/hybrid_async_demo.py
[More examples]: https://github.com/tmux-python/libtmux/tree/master/examples

## Final notes

Expand All @@ -521,3 +502,4 @@ and our [test suite] (see {ref}`development`.)
:::

[test suite]: https://github.com/tmux-python/libtmux/tree/master/tests
[ptpython]: https://github.com/prompt-toolkit/ptpython
1 change: 1 addition & 0 deletions docs/topics/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ pane_interaction
floating_panes
workspace_setup
automation_patterns
async_programming
context_managers
options_and_hooks
clients
Expand Down
162 changes: 162 additions & 0 deletions examples/async_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env python
"""Demonstration of async tmux command execution.

This example shows how the async-first architecture works with libtmux.
"""

from __future__ import annotations

import asyncio
import contextlib
import sys
import time
from pathlib import Path

# Try importing from installed package, fallback to development mode
try:
from libtmux.common_async import get_version, tmux_cmd_async
except ImportError:
# Development mode: add parent to path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from libtmux.common_async import get_version, tmux_cmd_async


async def demo_basic_command() -> None:
"""Demo: Execute a basic tmux command asynchronously."""
print("=" * 60)
print("Demo 1: Basic Async Command Execution")
print("=" * 60)

# Get tmux version asynchronously
print("\nGetting tmux version...")
version = await get_version()
print(f"tmux version: {version}")

# List all tmux sessions
print("\nListing all tmux sessions...")
proc = await tmux_cmd_async("list-sessions")

if proc.stderr:
print(f"No sessions found (or error): {proc.stderr}")
else:
print(f"Found {len(proc.stdout)} session(s):")
for line in proc.stdout:
print(f" - {line}")


async def demo_concurrent_commands() -> None:
"""Demo: Execute multiple tmux commands concurrently."""
print("\n" + "=" * 60)
print("Demo 2: Concurrent Command Execution")
print("=" * 60)

print("\nExecuting multiple commands in parallel...")

# Execute multiple tmux commands concurrently
results = await asyncio.gather(
tmux_cmd_async("list-sessions"),
tmux_cmd_async("list-windows"),
tmux_cmd_async("list-panes"),
tmux_cmd_async("show-options", "-g"),
return_exceptions=True,
)

commands = ["list-sessions", "list-windows", "list-panes", "show-options -g"]
for cmd, result in zip(commands, results, strict=True):
if isinstance(result, Exception):
print(f"\n[{cmd}] Error: {result}")
else:
print(f"\n[{cmd}] Returned {len(result.stdout)} lines")
if result.stderr:
print(f" stderr: {result.stderr}")


async def demo_comparison_with_sync() -> None:
"""Demo: Compare async vs sync execution time."""
print("\n" + "=" * 60)
print("Demo 3: Performance Comparison")
print("=" * 60)

from libtmux.common import tmux_cmd

# Commands to run
commands = ["list-sessions", "list-windows", "list-panes", "show-options -g"]

# Async execution
print("\nAsync execution (parallel)...")
start = time.time()
await asyncio.gather(
*[tmux_cmd_async(*cmd.split()) for cmd in commands],
return_exceptions=True,
)
async_time = time.time() - start
print(f" Time: {async_time:.4f} seconds")

# Sync execution
print("\nSync execution (sequential)...")
start = time.time()
for cmd in commands:
with contextlib.suppress(Exception):
tmux_cmd(*cmd.split())
sync_time = time.time() - start
print(f" Time: {sync_time:.4f} seconds")

print(f"\nSpeedup: {sync_time / async_time:.2f}x")


async def demo_error_handling() -> None:
"""Demo: Error handling in async tmux commands."""
print("\n" + "=" * 60)
print("Demo 4: Error Handling")
print("=" * 60)

print("\nExecuting invalid command...")
try:
proc = await tmux_cmd_async("invalid-command")
if proc.stderr:
print(f"Expected error: {proc.stderr[0]}")
except Exception as e:
print(f"Exception caught: {e}")

print("\nExecuting command for non-existent session...")
try:
proc = await tmux_cmd_async("has-session", "-t", "non_existent_session_12345")
if proc.stderr:
print(f"Expected error: {proc.stderr[0]}")
print(f"Return code: {proc.returncode}")
except Exception as e:
print(f"Exception caught: {e}")


async def main() -> None:
"""Run all demonstrations."""
print("\n" + "=" * 60)
print("libtmux Async Architecture Demo")
print("Demonstrating psycopg-inspired async-first design")
print("=" * 60)

try:
await demo_basic_command()
await demo_concurrent_commands()
await demo_comparison_with_sync()
await demo_error_handling()

print("\n" + "=" * 60)
print("Demo Complete!")
print("=" * 60)
print("\nKey Takeaways:")
print(" ✓ Async commands use asyncio.create_subprocess_exec()")
print(" ✓ Multiple commands can run concurrently with asyncio.gather()")
print(" ✓ Same API as sync version, just with await")
print(" ✓ Error handling works identically")
print(" ✓ Significant performance improvement for parallel operations")

except Exception as e:
print(f"\nDemo failed with error: {e}")
import traceback

traceback.print_exc()


if __name__ == "__main__":
asyncio.run(main())
Loading
Loading