Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
397271e
neo(feat[parse]): Add _split_records
tony Aug 29, 2026
1948adf
neo(fix[parse]): Regroup records on the separator
tony Aug 29, 2026
6c34c60
Common(feat[timeout]): Let a caller bound a tmux command
tony Aug 29, 2026
b04e60c
Server(feat[timeout]): Set the bound once, for every command
tony Aug 29, 2026
8e1d627
Command(feat): Add a separate process runner
tony Sep 13, 2026
1cfdf14
Query(feat): Expose typed public lookups
tony Sep 13, 2026
306b8f9
Models(feat): Add decoded scalar properties
tony Sep 13, 2026
06ee1fe
Server(feat): Add explicitly owned scopes
tony Sep 13, 2026
70df280
Docs(fix): Restore context manager walkthrough
tony Sep 13, 2026
68bf9c6
Tests(fix): Control the retry test clock
tony Sep 13, 2026
ceb39e2
CI(fix): Measure pytest plugin imports
tony Sep 13, 2026
5f760c2
Tests(test): Cover command and cleanup errors
tony Sep 13, 2026
38e855c
CI(fix): Cache tmux by resolved revision
tony Sep 13, 2026
6487180
Hooks(fix): Decode tmux 3.8 events
tony Sep 13, 2026
d0c7036
Docs(fix): Explain floating pane borders
tony Sep 13, 2026
0d96a9a
Coverage(test): Measure executable branches
tony Sep 13, 2026
8ff4aec
Control(test): Drain buffered replies
tony Sep 13, 2026
0bf7cc7
Capture(test): Wait for emitted markers
tony Sep 13, 2026
196e043
Ownership(test): Exercise cleanup refusals
tony Sep 13, 2026
2466d5e
Popup(test): Attach a terminal client
tony Sep 13, 2026
92ad98b
Ownership(test): Cover unused server scopes
tony Sep 13, 2026
a6cef8a
Docs(fix): Verify automation completion
tony Sep 13, 2026
4963acd
Control(fix): Close client streams on exit
tony Sep 13, 2026
0ae5cb4
Server(test): Require successful status messages
tony Sep 13, 2026
eb69f69
Tests(pane): Wait for capture prerequisites
tony Sep 13, 2026
6e6e9e5
Tests(window): Wait for split command output
tony Sep 13, 2026
ea33f7e
Tests: Wait for tmux terminal state
tony Sep 13, 2026
ae99cbd
Server(fix[new_session]): Regroup the -P -F reply before parsing
tony Sep 13, 2026
bc6dc31
Common(fix[timeout]): Bound the post-kill drain
tony Sep 13, 2026
648d252
Exc(fix[TmuxTimeout]): Keep self.args shaped like the constructor
tony Sep 13, 2026
a573f09
Neo(fix[parse]): Distinguish a malformed record from an unreachable s…
tony Sep 13, 2026
818771b
Server(fix[timeout]): Make liveness primitives honor Server.timeout
tony Sep 13, 2026
85e6d2a
Server(fix[owned_session]): Guard identity-building against leaking t…
tony Sep 13, 2026
2b288e0
Control(fix): SIGCONT a stopped client before waiting on termination
tony Sep 13, 2026
a2cf0b5
Tests(fix[common]): Exercise the timeout=None path it claims to test
tony Sep 13, 2026
60ac321
Server(fix): Give Server.timeout a class-level default
tony Sep 13, 2026
b762412
Server(fix[owned]): Store socket_path as str, matching every other co…
tony Sep 13, 2026
0616302
Server(fix[cmd]): Distinguish an omitted timeout from an explicit None
tony Sep 13, 2026
1b67370
Tests(fix[control_mode]): Bound cleanup time for a stopped client
tony Sep 15, 2026
0991bec
Tests(fix[control_mode]): Time only _stop(), not spawn/registration
tony Sep 15, 2026
4500a96
Server(fix[server_access]): Emit flags before the positional user
tony Sep 16, 2026
b2084bb
Tests(fix[3.8]): Pin pane_pid emptiness and the window_layout round trip
tony Sep 16, 2026
a596694
CI(fix[tests]): Gate the master-tmux matrix lane on its own test step
tony Sep 16, 2026
188dc25
Docs(feat[examples]): Add a runnable examples/ directory, executed by…
tony Sep 16, 2026
91b3403
Docs(feat[control-mode]): Decide ControlMode stays internal, document…
tony Sep 16, 2026
9bab333
Bench(feat): Add a pytest-benchmark suite for dispatch, listing, and …
tony Sep 16, 2026
b95bb65
Window(fix[select_layout]): Refuse an empty layout, guard hostile one…
tony Sep 16, 2026
418ed75
Pane(fix[send_keys,capture_pane]): Surface tmux failures, add left/top
tony Sep 16, 2026
8b32de7
Window(docs[select_layout]): State the round-trip identity caveat
tony Sep 16, 2026
fad203e
Server(fix[wait_for]): Accept a timeout, rename set_flag to signal
tony Sep 16, 2026
3582ca3
Docs(fix[relations]): Document which list accessors are lenient, and …
tony Sep 16, 2026
d4f493d
Server(fix[owned]): Trap SIGTERM/SIGHUP so cleanup still runs
tony Sep 16, 2026
bf4886f
Docs(fix[relations,is_dead]): Fix root AGENTS.md's blanket claim; wid…
tony Sep 16, 2026
97e0ffd
Window(fix[select_layout]): Refuse a leading-dash layout before tmux …
tony Sep 16, 2026
05bf8d8
Window(fix[select_layout]): Accept only a layout tmux can parse
tony Sep 17, 2026
3c64b6e
Neo(fix[refresh]): Clear a field that became empty
tony Sep 17, 2026
c09092b
Pane(fix[reset,split]): Surface tmux failures instead of a silent suc…
tony Sep 17, 2026
930e5e5
Docs(fix[readme]): Make the first .cmd() snippet runnable as published
tony Sep 17, 2026
c56547d
Tests(feat[coverage]): Cover the paths a reader never sees
tony Sep 19, 2026
8b36a52
Tests(fix[server_access]): Skip below the release that has it
tony Sep 19, 2026
c0ea83e
Server(fix[owned]): Re-raise the signal even if cleanup fails
tony Sep 19, 2026
bdc93ce
Docs(fix[writing]): List retry_until in doctest_namespace names
tony Sep 19, 2026
077b093
Docs(fix[comments]): Trim two comments to the one-two line ceiling
tony Sep 19, 2026
5b4702f
Server(docs[owned]): Document the two signal-cleanup gaps
tony Sep 19, 2026
2c5ffaf
Server(fix[wait_for]): Correct the wedged-lock recovery claim
tony Sep 19, 2026
60b6a35
Tests(fix[pytest_plugin]): Assert kill() directly instead of sleeping
tony Sep 19, 2026
39fadad
Examples(fix[quickstart]): Match the marker as a trailing substring
tony Sep 19, 2026
802be68
Commands(fix): Keep leading-dash text literal
tony Sep 19, 2026
9044cc4
Changelog(docs): Summarize API changes
tony Sep 19, 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
28 changes: 28 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ Test:
$ uv run pytest
```

Start coverage before pytest so plugin imports and worker processes are
measured:

```console
$ uv run coverage erase && \
uv run coverage run -m pytest -n auto && \
uv run coverage combine && \
uv run coverage xml
```

Documentation is a gate, not a courtesy. Examples in docstrings,
documentation pages, and `README.md` are executed by `pytest`; the
doctest flags live in `pyproject.toml`, so there is no separate doctest
Expand Down Expand Up @@ -128,6 +138,24 @@ Include doctests in the watch loop:
$ uv run ptw . --now --doctest-modules
```

## Benchmarks

`benchmarks/` holds [pytest-benchmark] microbenchmarks for command
dispatch, listing, snapshot capture, and format decoding. It is not a
gate — performance work is a separate tier from the gates above, not
part of them — and it is not in `testpaths`, so a plain `uv run pytest`
never runs it.

```console
$ just bench
```

Report a regression with the printed numbers, not a guess. A number
that lands in a commit message or `CHANGES` is a measurement someone
ran, not a target to defend in the next one.

[pytest-benchmark]: https://pytest-benchmark.readthedocs.io/

## Debugging

Stuck in a debugging loop: pause and acknowledge it rather than trying
Expand Down
8 changes: 4 additions & 4 deletions .github/WRITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,10 @@ cannot pass, fix the example or fix the code.
doctest in the suite runs, so a block never needs an import or a setup
preamble to reach these names: `server`, `session`, `window`, `pane`,
`Server`, `Session`, `Window`, `Pane`, `Client`, `ControlMode`,
`control_mode`, `monkeypatch`, `request`. It only does this inside an
actual doctest item and only when `tmux` is on `PATH` — a doctest that
uses one of these names with no `tmux` binary available fails with a
`NameError`, not a skip.
`control_mode`, `retry_until`, `monkeypatch`, `request`. It only does
this inside an actual doctest item and only when `tmux` is on `PATH` —
a doctest that uses one of these names with no `tmux` binary available
fails with a `NameError`, not a skip.

**The `Server` trap.** `conftest.py` assigns `doctest_namespace["Server"]`
twice: first to the real `libtmux.server.Server` class, then — a few
Expand Down
36 changes: 26 additions & 10 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
save-cache: ${{ strategy.job-index == 0 }}

- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
Expand All @@ -36,24 +37,41 @@ jobs:
- name: Install dependencies
run: uv sync --all-extras --dev

- name: Resolve tmux revision
id: tmux-source
env:
TMUX_REF: ${{ matrix.tmux-version }}
run: |
if [[ "$TMUX_REF" == master ]]; then
ref=refs/heads/master
else
ref="refs/tags/$TMUX_REF"
fi
revision=$(git ls-remote --refs https://github.com/tmux/tmux.git "$ref")
revision=${revision%%[[:space:]]*}
[[ "$revision" =~ ^[0-9a-f]{40}$ ]]
printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT"

- name: Setup tmux build cache for tmux ${{ matrix.tmux-version }}
id: tmux-build-cache
uses: actions/cache@v6
with:
path: ~/tmux-builds/tmux-${{ matrix.tmux-version }}
key: tmux-${{ matrix.tmux-version }}
key: tmux-${{ runner.os }}-${{ runner.arch }}-${{ matrix.tmux-version }}-${{ steps.tmux-source.outputs.revision }}

- name: Build tmux ${{ matrix.tmux-version }}
if: steps.tmux-build-cache.outputs.cache-hit != 'true'
env:
TMUX_REVISION: ${{ steps.tmux-source.outputs.revision }}
run: |
sudo apt install libevent-dev libncurses5-dev libtinfo-dev libutempter-dev bison
mkdir ~/tmux-builds
mkdir ~/tmux-src
git clone https://github.com/tmux/tmux.git ~/tmux-src/tmux-${{ matrix.tmux-version }}
cd ~/tmux-src/tmux-${{ matrix.tmux-version }}
git checkout ${{ matrix.tmux-version }}
git checkout "$TMUX_REVISION"
sh autogen.sh
./configure --prefix=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }} && make && make install
./configure --prefix="$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}" && make && make install
export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH
cd ~
tmux -V
Expand All @@ -73,17 +91,15 @@ jobs:
uv run python -V

- name: Test with pytest
continue-on-error: ${{ matrix.tmux-version == 'master' }}
run: |
sudo apt install libevent-2.1-7
export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH
ls $HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin
ls "$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin"
tmux -V
uv run py.test --cov=./ --cov-append --cov-report=xml -n auto --verbose
env:
COV_CORE_SOURCE: .
COV_CORE_CONFIG: .coveragerc
COV_CORE_DATAFILE: .coverage.eager
uv run coverage erase
uv run coverage run -m pytest -n auto --verbose
uv run coverage combine
uv run coverage xml
- uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
Expand Down
18 changes: 13 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,19 @@ be stated twice, the file listed above is the one that governs.
tmux >= 3.2a is the compatibility floor (see `tests.yml`'s build
matrix). `Server.sessions`, `Server.clients`, and
`Server.attached_sessions` return an empty `QueryList` rather than
raising when the underlying tmux list command fails for any reason —
list-shaped accessors are lenient by default; `Server.is_alive()` and
`Server.raise_if_dead()` are the explicit, loud-failure primitives. See
`src/libtmux/AGENTS.md` for the full contract and this package's
logging conventions.
raising when the underlying tmux list invocation fails for any reason.
This does not generalize to every list-shaped accessor: `Server.windows`
and `Server.panes` are lenient only for a not-yet-started daemon or a
missing socket, and `Session.windows`, `Session.panes`, `Window.panes`,
and `Window.search_panes` are not lenient at all — any tmux failure
there raises. `Server.is_alive()` and `Server.raise_if_dead()` are the
explicit, loud-failure primitives; a dead server reading as an empty
live one through the lenient accessors never implies a `Session`/
`Window` relation obtained beforehand will also read empty rather than
raise. A parse failure (`exc.TmuxRecordParseError`) or a timeout
(`exc.TmuxTimeout`) still propagates through the lenient ones — see
`src/libtmux/AGENTS.md` for the full, precise contract and this
package's logging conventions.

## References

Expand Down
91 changes: 91 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,86 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### What's new

#### Completed command results (#758)

{func}`~libtmux.common.run_command` returns captured output and exit status
as a {class}`~libtmux.common.CommandResult`, including nonzero exits.

#### Optional command timeouts (#758)

{attr}`~libtmux.Server.timeout` bounds commands, listings and liveness
checks. {meth}`~libtmux.Server.cmd` and {meth}`~libtmux.Server.wait_for`
accept per-call overrides. Expiry raises {exc}`~libtmux.exc.TmuxTimeout`;
a timed-out command may already have taken effect.

#### Signal a waiting channel (#758)

Use `signal=True` with {meth}`~libtmux.Server.wait_for` to wake waiters.
The previous `set_flag` keyword remains a deprecated alias.

#### Temporary servers and sessions (#758)

{meth}`~libtmux.Server.owned` creates a private server scope;
{meth}`~libtmux.Server.owned_session` creates a temporary session on an
existing server. Both clean up their own resources on scope exit and
report cleanup failures. See {doc}`topics/context_managers`.

#### Decoded captured fields (#758)

Pane and window properties expose dimensions and activity as numbers and
booleans. Panes also expose position and {attr}`~libtmux.Pane.is_dead`;
sessions expose {attr}`~libtmux.Session.attached_count`. Values come from
the last capture; refresh the object for current state.

#### Public local queries (#758)

Import {class}`~libtmux.QueryList` from the package root. Required lookups
and lookups with defaults preserve their distinct result types.

### Fixes

#### Invalid layouts are rejected before dispatch (#758)

{meth}`~libtmux.Window.select_layout` rejects invalid or flag-like values
before they can affect the server. Use a preset, an unambiguous preset
prefix or a saved layout; use `None` to omit the layout instead of `""`.

#### Leading-dash arguments remain literal (#758)

Command text, names, messages and paths beginning with `-` are passed as
values instead of being interpreted as tmux flags.

#### Pane operations report tmux refusals (#758)

{meth}`~libtmux.Pane.send_keys`, {meth}`~libtmux.Pane.capture_pane`,
{meth}`~libtmux.Pane.enter`, {meth}`~libtmux.Pane.reset` and
{meth}`~libtmux.Pane.split` raise {exc}`~libtmux.exc.LibTmuxException`
when tmux refuses the operation.

#### Listings preserve paths containing newlines (#758)

Newlines in paths no longer break listings or
{meth}`~libtmux.Server.new_session`. Unparseable records raise
{exc}`~libtmux.exc.TmuxRecordParseError`, including in otherwise lenient
server listings.

#### Refresh clears fields that became empty (#758)

Refreshing a pane, window, session or client clears fields tmux now
reports empty instead of retaining their previous values.

#### Additional tmux hooks decode correctly (#758)

Reading hooks recognizes additional pane, window, client and command
events supported by newer tmux releases.

#### Server access accepts flags with a user (#758)

{meth}`~libtmux.Server.server_access` accepts calls that combine access
flags with a user instead of failing with an argument error.

### Documentation

#### Cleaner `from_env` examples (#719)
Expand All @@ -57,6 +137,12 @@ environment-setup plumbing, so each example leads with the constructor call it
demonstrates instead of the socket-path and `$TMUX` boilerplate needed to run
it.

#### Runnable automation examples (#758)

Standalone scripts cover command results, temporary resources and
polling for changes. Each uses its own server, leaving existing sessions
alone. See {doc}`topics/examples`.

### Development

#### CI actions updated to current majors
Expand Down Expand Up @@ -87,6 +173,11 @@ parsers, {meth}`Server.is_alive() <libtmux.Server.is_alive>`, and the Sphinx
config carry scoped per-file ignores where catching everything is the intended
contract.

#### Benchmarks for library operations (#758)

Run `just bench` to measure command dispatch, listings, captures and
format decoding separately from the test suite.

## libtmux 0.62.0 (2026-07-12)

libtmux 0.62.0 teaches libtmux objects to locate themselves and to resolve
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ Server(socket_path=/tmp/tmux-.../default)
**Tip:** You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your
current tmux server / session / window / pane.

Prefer a script over a REPL? [`examples/`][examples] holds standalone,
runnable programs — `python examples/quickstart.py` and no existing session
required.

[ptpython]: https://github.com/prompt-toolkit/ptpython
[ipython]: https://ipython.org/
[`tmuxp shell`]: https://tmuxp.git-pull.com/cli/shell/
Expand All @@ -125,8 +129,9 @@ current tmux server / session / window / pane.
Every object has a `.cmd()` escape hatch that honors socket name and path:

```python
>>> server = Server(socket_name='libtmux_doctest')
>>> server.cmd('display-message', 'hello world')
>>> from libtmux.server import Server
>>> with Server(socket_name='libtmux_doctest') as server:
... server.cmd('display-message', 'hello world')
<libtmux...>
```

Expand Down Expand Up @@ -314,7 +319,8 @@ def test_my_tmux_tool(session):
[Workspace Setup](https://libtmux.git-pull.com/topics/workspace_setup/) ·
[Automation Patterns](https://libtmux.git-pull.com/topics/automation_patterns/) ·
[Context Managers](https://libtmux.git-pull.com/topics/context_managers/) ·
[Options & Hooks](https://libtmux.git-pull.com/topics/options_and_hooks/)
[Options & Hooks](https://libtmux.git-pull.com/topics/options_and_hooks/) ·
[Examples](https://libtmux.git-pull.com/topics/examples/)

**Reference:**
[Docs][docs] ·
Expand Down Expand Up @@ -350,3 +356,4 @@ Contributions are welcome. Please open an issue or PR if you find a bug or want
[tao]: https://leanpub.com/the-tao-of-tmux
[tmuxp]: https://tmuxp.git-pull.com
[tmux]: https://github.com/tmux/tmux
[examples]: https://github.com/tmux-python/libtmux/tree/master/examples
51 changes: 51 additions & 0 deletions benchmarks/bench_capture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Benchmark: Pane.capture_pane() snapshot capture.

Run with::

$ just bench

Equivalent to::

$ uv run pytest benchmarks/ -o python_files='bench_*.py' --benchmark-only

Not part of ``pytest``'s default run -- see bench_dispatch.py.
"""

from __future__ import annotations

import pytest
import pytest_benchmark.fixture

from libtmux.pane import Pane
from libtmux.session import Session
from libtmux.test.retry import retry_until

_SCROLLBACK_LINES = 200


@pytest.fixture
def pane_with_scrollback(session: Session) -> Pane:
"""Return a pane with a full screen of numbered scrollback lines."""
window = session.new_window(window_name="bench-capture", window_shell="sh")
pane = window.active_pane
assert pane is not None
fill_command = (
f"i=0; while [ $i -lt {_SCROLLBACK_LINES} ]; do "
"echo line-$i; i=$((i+1)); done; echo capture-bench-done"
)
pane.send_keys(fill_command)
retry_until(
lambda: any(
line.rstrip(" ") == "capture-bench-done" for line in pane.capture_pane()
),
raises=True,
)
return pane


def test_bench_capture_pane(
benchmark: pytest_benchmark.fixture.BenchmarkFixture,
pane_with_scrollback: Pane,
) -> None:
"""capture_pane(): one list-panes read plus the visible screen text."""
benchmark(pane_with_scrollback.capture_pane)
29 changes: 29 additions & 0 deletions benchmarks/bench_dispatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Benchmark: Server.cmd() dispatch, the primitive every wrapper method uses.

Run with::

$ just bench

Equivalent to::

$ uv run pytest benchmarks/ -o python_files='bench_*.py' --benchmark-only

Not part of ``pytest``'s default run -- ``benchmarks/`` is not in
``testpaths``, and performance work is a separate tier from the test-loop
budgets in CONTRIBUTING.md, not part of them.
"""

from __future__ import annotations

import pytest_benchmark.fixture

from libtmux.session import Session


def test_bench_command_dispatch(
benchmark: pytest_benchmark.fixture.BenchmarkFixture,
session: Session,
) -> None:
"""One round trip through Server.cmd(): fork, exec, read, parse exit."""
server = session.server
benchmark(server.cmd, "display-message", "-p", "#{session_name}")
Loading
Loading