Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .github/WRITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ docstring. `ELLIPSIS` and `NORMALIZE_WHITESPACE` are enabled globally
differences do not fail a comparison. Reach for an inline
`# doctest: +FLAG` only for the block that needs something more.

**Standalone programs.** `tests/test_example_harness.py` compares the
ordinary README program with `examples/workspace.py` and runs that file
unchanged in a child process, with an absent or an existing daemon. It also
runs `examples/session_scope.py` as a cleanup demonstration. These unprompted
README blocks use this test instead of the doctest collector. The harness
supplies endpoint defaults and owns final teardown after successful execution
and a body exception.

**`# doctest: +SKIP` is not permitted.** It is a workaround that tests
nothing. Use the fixtures.

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

### Lifecycle discovery and find or create

`Server.discover()` scans explicit and captured tmux socket directories with entry, probe and time budgets. Results include borrowed servers, failed/skipped-path diagnostics and truncation. Probes do not start daemons or write ownership metadata.

`Server.find_or_create()`, `Server.find_or_create_session()`, `Session.find_or_create_window()` and `Window.find_or_create_pane()` return `FoundOrCreated` scopes. A created result owns its resource; a reused result leaves it alive. Child lookup matches full names or local pane keys and raises on ambiguous matches or command failures. Server creation uses a per-call startup nonce before accepting whole-daemon ownership. Known receipts retain guarded rollback after client failure, interruption or failed materialization.

Discovery and lifecycle identity checks retain their fields with a `C` client locale.

### Ordinary examples with or without a running server

{meth}`~libtmux.Server.ensure_running` starts the selected daemon when needed and returns the same server handle. It keeps a new daemon alive without a bootstrap session and preserves an existing daemon's sessions and configuration. The daemon remains available after the handle leaves scope. The README's ordinary example creates or reuses a session and window, with test endpoint selection and teardown supplied by the external harness.

### Breaking changes

#### Server context and executable capture

A plain `Server` context leaves remote state intact. Use `server.own()` to accept whole-daemon destruction.

`Server` captures an absolute tmux executable path from its construction-time `PATH` and working directory. Commands, object queries, control clients, health checks and version probes use that executable and the captured child environment. A missing executable stays unresolved until a new handle is constructed. The read-only `tmux_bin` property reports the captured path or `None`. Version caches belong to each server handle; standalone `get_version()` and `get_version_str()` retain their existing cache behavior.

#### Captured server endpoints

{class}`~libtmux.Server` resolves explicit socket selectors, then
`LIBTMUX_SOCKET_PATH`, `LIBTMUX_SOCKET_NAME`, `TMUX`, and the default socket.
It retains the absolute endpoint and client environment for its lifetime.
Explicit paths must be absolute; passing both selectors raises `ValueError`.
Socket names reject separators, NUL, `.` and `..`. Named/default selectors
require an absolute `TMUX_TMPDIR` when set, and a missing root raises instead
of falling back to another endpoint. Create a new handle to select a changed
environment.

```python
import pathlib
import libtmux

# Before
server = libtmux.Server(socket_path="relative.sock")
# After
server = libtmux.Server(socket_path=pathlib.Path("relative.sock").absolute())
```

### What's new

#### Recovery after failed creation

Session, window and pane creation retain the creating daemon's token and object ID before decoding snapshots. Failed readback or context entry rolls back that resource against the original daemon. Nonzero client results, timeouts and interruptions retain readable creation receipts. `CreationCleanupError` exposes a failed rollback's owner for inspection and retry; `UnknownCreation` reports missing or unreadable receipts without claiming that no resource was created. Creation now reserves `@libtmux_owner_generation`, and an existing malformed value fails before the create command.

#### Explicit remote ownership

Call `.own()` on a server, session, window or pane to accept destruction responsibility. Owners retain the endpoint, daemon token and object ID, refuse replacement daemons, preserve paired body/cleanup failures and permit retry after failed teardown. Adoption reserves the server option `@libtmux_owner_generation`; invalid existing values fail acceptance. Session, window and pane contexts use the same ownership behavior. See {doc}`topics/context_managers` for complete examples.

`resource.own(timeout=...)` bounds acceptance and cleanup, including time spent waiting for another close call. `Server.cmd(timeout=...)` terminates and reaps a timed-out client. Output draining and reaping each allow up to 0.1 additional seconds, even when another process holds the client's output pipes. A timeout cannot undo a remote operation that the client already dispatched.

#### Client environment isolation and object cleanup

{class}`~libtmux.Server` accepts `child_environment` overrides and exposes an
immutable captured mapping. Client launches omit `TMUX` and `TMUX_PANE`
without changing the host environment. Session, window and pane scopes target
object IDs, including after a rename or a move to another parent.
`BaseExceptionGroup` retains both a body exception and a cleanup exception. Python 3.10 uses the `exceptiongroup` backport; Python 3.11 and later use the built-in exception group.
A failed cleanup can be retried; a prior successful destruction is harmless.
`temp_window` uses the window scope's cleanup. Pytest finalizers retain exact
endpoints and report cleanup failures.

{class}`~libtmux.test.environment.EnvironmentVarGuard` restores each key's
original state after repeated edits, including prior absence and exceptions.
See {doc}`topics/configuration` for endpoint precedence and the separate
client and tmux environment APIs.

### Documentation

#### Cleaner `from_env` examples (#719)
Expand Down
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,64 @@ libtmux = "0.50.*"

## 🚀 Quickstart

Create or reuse a session and window at your configured tmux endpoint. The same
program works whether tmux is already running or this is your first session.
Save it as `workspace.py`, or run
[`examples/workspace.py`](examples/workspace.py):

```python
"""Create or reuse a session and window at the ordinary configured endpoint."""

from __future__ import annotations

import libtmux

server = libtmux.Server().ensure_running()
session = server.find_or_create_session("libtmux-example").value
window = session.find_or_create_window("work").value
pane = window.panes[0]
print(session.session_name, window.window_name, pane.pane_id, flush=True)
```

`ensure_running()` returns the same `Server` handle. It starts the daemon when
needed without adding a bootstrap session and preserves an existing daemon's
sessions and configuration.
The program leaves `libtmux-example` and its `work` window available. Repeating
it reuses those names. A find-or-create result's `.value` gives you the ordinary
session or window; entering the result as a context manager instead requests
cleanup of an object it creates.

`Server()` captures its endpoint at construction: explicit `socket_path` or
`socket_name`, then `LIBTMUX_SOCKET_PATH`, `LIBTMUX_SOCKET_NAME`, `TMUX`, or the
default socket. For named/default sockets, `TMUX_TMPDIR` selects the root.
Empty selector variables count as absent. Later environment changes do not
redirect an existing handle. The [external harness](tests/test_example_harness.py)
runs this file unchanged with both private path and name defaults, starting
with an absent or an existing daemon. The harness owns test cleanup.

### Clean up a session at scope exit

Use a session context when you want to remove a session after your work finishes.
Save this program as `session_scope.py`, or run
[`examples/session_scope.py`](examples/session_scope.py):

```python
"""Create and clean up a session at the ordinary configured endpoint."""

from __future__ import annotations

import uuid

import libtmux

server = libtmux.Server()
with server.new_session(session_name=f"libtmux-example-{uuid.uuid4().hex}") as session:
print(session.session_id, flush=True)
```

The harness also runs this cleanup demonstration unchanged with both endpoint
defaults, including a body failure that must still remove the session.

### Open a tmux session

First, start a tmux session to connect to:
Expand All @@ -110,7 +168,7 @@ Connect to a live tmux session:
>>> import libtmux
>>> svr = libtmux.Server()
>>> svr
Server(socket_path=/tmp/tmux-.../default)
Server(socket_path=.../tmux-.../default)
```

**Tip:** You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your
Expand Down
1 change: 1 addition & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,5 @@ Options <libtmux.options>
Hooks <libtmux.hooks>
Constants <libtmux.constants>
Exceptions <libtmux.exc>
Ownership <libtmux.lifecycle>
```
13 changes: 13 additions & 0 deletions docs/api/libtmux.lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Ownership

```{eval-rst}
.. automodule:: libtmux.lifecycle
:members: Owned, OwnedIdentity, FoundOrCreated, AmbiguousMatch, CreationCleanupError, UnknownCreation
```

## Discovery results

```{eval-rst}
.. automodule:: libtmux.discovery
:members: DiscoveryResult, DiscoveryDiagnostic, DiscoveredServer
```
4 changes: 3 additions & 1 deletion docs/api/libtmux.neo.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ Attach default tmux {class}`~libtmux.Server` to `t`:
>>> import libtmux
>>> t = libtmux.Server()
>>> t
Server(socket_path=/tmp/tmux-.../default)
Server(socket_path=...)
```

The representation shows the absolute endpoint selected from your arguments or environment. Its location depends on your tmux configuration.

## Session

Get the {class}`~libtmux.Session` object:
Expand Down
14 changes: 13 additions & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ via trunk (can break easily):
[uvx]: https://docs.astral.sh/uv/guides/tools/
[ptpython]: https://github.com/prompt-toolkit/ptpython

## Create or reuse a session

Run this program with or without an existing tmux daemon. {meth}`~libtmux.Server.ensure_running` starts the selected daemon when needed. The program uses your configured endpoint, creates or reuses the named session and window, and leaves them available afterward.

```{literalinclude} ../examples/workspace.py
:language: python
```

The {attr}`~libtmux.FoundOrCreated.value` property gives you the session or window handle. Repeating the program reuses those names. See {ref}`context_managers` when you want to remove objects at scope exit. The interactive walkthrough below shows how to work with a session you start from your terminal.

## Start a tmux session

Now, let's open a tmux session.
Expand Down Expand Up @@ -148,9 +158,11 @@ First, we can grab a {class}`~libtmux.Server`.
>>> import libtmux
>>> server = libtmux.Server()
>>> server
Server(socket_path=/tmp/tmux-.../default)
Server(socket_path=...)
```

The representation shows the absolute endpoint selected from your arguments or environment. Its location depends on your tmux configuration.

:::{tip}

You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your
Expand Down
109 changes: 64 additions & 45 deletions docs/topics/configuration.md
Original file line number Diff line number Diff line change
@@ -1,58 +1,77 @@
# Configuration

You configure libtmux through Python: there are no config files, and you
set everything through method calls on {class}`~libtmux.Server`,
{class}`~libtmux.Session`, {class}`~libtmux.Window`, and
{class}`~libtmux.Pane` objects, with sensible defaults. If you're driving
tmux through the standard object API, you're already configured correctly
and can stop reading here.

The rest of this page is for the rarer cases. It documents two lower
layers you can reach for when the defaults aren't enough: the
environment variables libtmux reads, and the format-string system
libtmux uses internally to read tmux state.
{class}`~libtmux.Server` captures one tmux endpoint when you construct it.
You can use ordinary defaults, pass a socket selector, or configure the same
program from its launch environment. Commands and cleanup retain that
endpoint after you change the host environment.

## Environment variables

You set almost nothing here. The two variables that matter most, tmux
writes for you and libtmux only reads back, so a normal Python process
driving tmux has nothing to arrange in this section.
The first selected value wins:

tmux exports both into every pane it spawns:
1. An explicit `socket_path` or `socket_name`. Passing both raises `ValueError`.
2. Nonempty `LIBTMUX_SOCKET_PATH`.
3. Nonempty `LIBTMUX_SOCKET_NAME`.
4. Nonempty `TMUX`, parsed as `socket_path,server_pid,session_id`.
5. The named `default` socket.

| Variable | What tmux puts in it |
|---|---|
| `TMUX` | the server that pane belongs to, as `socket_path,server_pid,session_id` |
| `TMUX_PANE` | the id of the pane itself, e.g. `%1` |
Empty environment selectors count as absent. An invalid selected value raises
without trying a lower priority selector. Lower priority values cannot
invalidate an explicit choice. Paths must be absolute and contain no NUL;
libtmux preserves spaces and commas. Socket names must be nonempty leaf
names without `/`, `\`, NUL, `.` or `..`.

Code running *inside* a pane — a script you started in a split, a hook, a
test harness — reads them back to get a handle on itself, rather than
searching the server for a pane it already is. That is the `from_env`
family: {meth}`Server.from_env() <libtmux.Server.from_env>`,
For a named/default socket, libtmux captures nonempty `TMUX_TMPDIR` or `/tmp`
and derives `<root>/tmux-<uid>/<name>`. The supplied root must be absolute
and exist when you issue a command. Libtmux creates the private per-UID
directory if needed, checks its owner and permissions, and passes the captured
path to tmux. A missing or removed root raises; an explicit path does not
create its parent directory. `socket_path` exposes the captured absolute
path for named sockets too. `socket_name_factory` supplies an explicit name
when you omit both explicit selectors.

`TMUX` splits from the last two commas so the socket path can contain commas.
Its PID must be positive ASCII decimal; its session field accepts a
nonnegative ASCII decimal ID, one optional `$` prefix, or tmux's `-1`
no-session sentinel. Malformed context raises
{exc}`~libtmux.exc.NotInsideTmux`. The selected path still requires an
absolute path. {meth}`Server.from_env() <libtmux.Server.from_env>` reads a
supplied mapping or the host `TMUX` context; child
{meth}`Session.from_env() <libtmux.Session.from_env>`,
{meth}`Window.from_env() <libtmux.Window.from_env>`, and
{meth}`Pane.from_env() <libtmux.Pane.from_env>`. Outside a pane neither
variable is set, and all four raise {exc}`~libtmux.exc.NotInsideTmux`.
You never write them yourself: {ref}`self-location` covers what each call
does with them, why the session id in `TMUX` goes stale, and the `env`
mapping you hand `from_env` in tests instead of touching the real
environment.

tmux reads `TMUX` too — it is how tmux notices you are already inside a
session and guards against nesting one. {meth}`Server.new_session()
<libtmux.Server.new_session>` unsets it for the length of that one call
and restores it afterward, so creating a session from inside a pane works
without you arranging anything.

That leaves the two variables that *are* yours to set, and most people
set neither. `TMUX_TMPDIR` is tmux's own — the directory it keeps sockets
in. libtmux never reads it, but the tmux binary it shells out to does, so
it shapes which server a bare {class}`~libtmux.Server` lands on; pass
`socket_name` or `socket_path` when you would rather name the server
outright. `LIBTMUX_TMUX_FORMAT_SEPARATOR` is the one variable libtmux
itself defines: an advanced override for the separator (default `␞`) it
uses internally to parse tmux's format output — you'd touch it only if
that character ever collided with your own data.
{meth}`Pane.from_env() <libtmux.Pane.from_env>` also resolve `TMUX_PANE`
against the live server. See {ref}`self-location` for pane context.

## Client and tmux environments

`child_environment` supplies overrides for a copy of the host environment at
construction. The endpoint resolver reads that copy, then client launches
receive its immutable snapshot with `TMUX` and `TMUX_PANE` removed. Commands,
including {meth}`Server.new_session() <libtmux.Server.new_session>`, leave the
host environment unchanged. `child_environment` cannot redirect an explicit
socket selector. There is no `LIBTMUX_SOCKET_ENV` variable.

The constructor also resolves `tmux_bin` against the captured `PATH` and working directory. `server.tmux_bin` reports that absolute path, or `None` when a bare executable name was not found. Adding an executable to `PATH` later does not repair an existing handle; construct a new one. The captured path preserves filesystem traversal, including symlinks and `..`; it does not pin the executable's bytes against replacement on disk. Commands, object queries, control clients, health checks and version probes all use this path and the captured environment. Each handle caches its own version probe. The standalone `libtmux.common.get_version()` and `get_version_str()` functions keep their separate process-wide caches.

The `environment=` argument to session, window and pane creation configures
tmux's environment for those resources. Methods such as
{meth}`~libtmux.common.EnvironmentMixin.set_environment` change tmux's
server/session environment. They do not edit a handle's captured client
environment or restore earlier tmux values on scope exit.

{class}`~libtmux.test.environment.EnvironmentVarGuard` edits process-global
variables and restores their original value or absence after repeated edits
and exceptions. Concurrent writers share that host state. Prefer a child
environment for an external example harness: it leaves the parent's
environment unchanged and keeps whole-server cleanup outside the ordinary
program. The repository's `tests/test_example_harness.py` executes
`examples/session_scope.py` unchanged using both socket selector variables,
checks session cleanup after success and body failure, then destroys its
private server. On Linux it checks daemon exit through a retained PID handle
and checks the endpoint before releasing its temporary root.

`LIBTMUX_TMUX_FORMAT_SEPARATOR` configures the separator (default `␞`) used
to parse tmux's format output. It has no role in endpoint selection.

## Format strings

Expand Down
Loading
Loading