Metadata-Version: 2.4
Name: libtmux-easy
Version: 0.4.0
Summary: tmux sessions from Python: exact pane exit status, a live dashboard, pytest fixtures
Author-email: Spencer Diehl <docdiehl@gmail.com>
License: Apache-2.0
Project-URL: Homepage, https://github.com/nasa-jpl/libtmux_easy
Project-URL: Bug Tracker, https://github.com/nasa-jpl/libtmux_easy/issues
Project-URL: Repository, https://github.com/nasa-jpl/libtmux_easy
Keywords: tmux,terminal,multiplexer,session,dashboard,pytest
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Shells
Classifier: Topic :: Terminals
Requires-Python: >=3.8.1
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: libtmux>=0.17
Requires-Dist: psutil
Requires-Dist: packaging
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Provides-Extra: dash
Requires-Dist: textual>=1.0; extra == "dash"
Dynamic: license-file

# libtmux-easy

Build tmux sessions from Python and monitor/control what runs in them. 

## Overview

Built on top of the `libtmux` library, this library handles common boilerplate when programatically creating a tmux session. The library features fall primarily into a few categories:

1. **Simplify creating a tmux session** - minimal code is required for creating a session. As easy as:

    ```python
    from libtmux_easy import TmuxSession
    session = TmuxSession.create("example")
    session.new_window("work")
    session.new_pane("shell", cmd="echo 'a plain shell'")
    session.new_window("play")
    session.new_pane("top", cmd="top")
    session.attach()
    ```

    In this example, we create a session named "example", and then create 2 windows, each with a single pane running a command. Notice that:
    - Windows and panes get names. `libtmux_easy` requires that windows/panes are named. These names are visible in the tmux session.
    - The setup is "stateful". E.g. `new_pane()` will be create a new pane in the last window that was created, unless a specific target window is specified.

2. **Process monitoring and control** - each pane tracks the process its command started, not just the pane. From Python you can ask whether that process is running, how it exited, and what it printed, and you can stop or rerun it:

    ```python
    server = session.new_pane("server", cmd="python3 -m http.server", mode="wrap",
                              on_exit=lambda status: print(status))
    session.wait_for_output(server, "Serving HTTP", timeout=5)
    session.is_running(server)   # True
    session.stop_pane(server)    # Ctrl-C, escalating if ignored
    session.exit_status(server)  # exact code, or the signal
    ```

    Each pane runs in one of three modes: `shell`, `wrap` or `exec`. The mode trades exit-status accuracy against interactivity; `wrap` gives both. See [Pane modes](#pane-modes).

3. **Dashboard** - a TUI that lists every pane by window with the live status of its process. The selected pane is swapped into the viewport on the right. It is the real pane, not a copy, so you can type into it, scroll it or Ctrl-C it. Open it from a script with `session.dashboard()`, or wrap a script/command with `lte-session -- CMD`  to watch the panes it starts.

    In the example below, `servers:clock` is selected and it's pane is swapped into view. We can also immediately see that `jobs:passes` exited 0 and `jobs:fails` exited 1.

    <img src="docs/lte-dash.png" width="600" alt="lte-dash: pane list with statuses on the left, the selected pane on the right">

    See [Dashboard](#dashboard) and [lte-session](#lte-session) for more information.

4. **Pytest integration** - a **pytest plugin** that launches the processes a test needs and fails the test if one dies. A test class starts them in `setup_class`, waits until they are ready, and stops them in `teardown_class`:

    ```python
    # conftest.py
    pytest_plugins = ["libtmux_easy.testing"]

    # test_web.py
    from libtmux_easy.testing import Processes

    class TestWeb:
        @classmethod
        def setup_class(cls):
            cls.procs = Processes(cls)
            cls.procs.start("servers:web", "python3 -m http.server 8000", ready=r"Serving HTTP")

        @classmethod
        def teardown_class(cls):
            cls.procs.close()
    ```

    - **fails on its own**: a test fails if a process exits early or prints a `fail_on` line, even when the test never checked;
    - **runs anywhere**: plain subprocesses by default, tmux panes with `--tmux`, or a live dashboard under `lte-session`;
    - **debuggable**: `--keep-open` leaves the processes running after the run, for attaching and poking at them.

    See [Testing with pytest](#testing-with-pytest). The same `Processes`, without pytest, is
    `libtmux_easy.processes.Processes`, for a launcher or a script: see
    [docs/processes.md](docs/processes.md).


## Install

Linux only.

| you want | install |
|---|---|
| library and commands, in a venv | `pip install 'libtmux-easy[dash]'` |
| the commands only, any distro | `pipx install 'libtmux-easy[dash]'` |
| Ubuntu system Python (ROS) | both `.deb`s from [Releases](https://github.com/nasa-jpl/libtmux_easy/releases) |
| to develop | `uv sync` in a checkout |

```bash
pip install 'libtmux-easy[dash]'
# the latest main:
pip install 'libtmux-easy[dash] @ git+https://github.com/nasa-jpl/libtmux_easy'
# Ubuntu: both .debs from https://github.com/nasa-jpl/libtmux_easy/releases
sudo apt install ./python3-libtmux-easy_*_all.deb ./libtmux-easy-dash_*_all.deb
```

- Needs tmux. `[dash]` adds Textual for the dashboard; everything else works without it.
- Wrap-mode panes run `lte-run`: it must be on the panes' PATH, not only the
  script's (the `.deb`, pipx, or `send_command_before("source .venv/bin/activate")`).
- ROS: a venv made with `python3 -m venv --system-site-packages` sees `rclpy`.
- More (pipx with the `.deb`, `--user` installs, building the `.deb`s):
  [docs/packaging.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/packaging.md).

## Quick start

```python
from libtmux_easy import TmuxSession

session = TmuxSession.create("my-session")
session.new_window("work")
session.new_pane("editor", "nvim")
server = session.new_pane("server", "python3 -m http.server", mode="wrap")

session.new_window("logs", layout="main-horizontal")
session.new_pane("journal", "journalctl -f")

session.wait_for_output(server, r"Serving HTTP", timeout=10)
session.attach()
```

Panes are named `window:pane`; anything that takes a pane also takes that
name, its id (`%3`) or the `Pane`. Each pane's border shows its state
(`server [running]`, `server [exited 3]`).

## Pane modes

| `mode=` | the command | exit status | interactive |
|---|---|---|---|
| `"shell"` (default) | is typed into bash | from `$?`: a signal death is *inferred* from 128+N | yes |
| `"wrap"` | is typed as `lte-run <cmd>` | from `lte-run`, exact | yes |
| `"exec"` | is the pane's process | from tmux, when tmux records it (see below) | no |

**Use wrap** to monitor a process and get its exit status: exact, and the
pane stays a shell you can type into. Shell mode is fine when 128+N is good
enough.

**Avoid exec mode for status.** tmux (3.3a, 3.4) sometimes never collects
an exec pane's exited process, so the status is lost: `exit_status()`
returns unknown. It is often lost for a pane stopped with Ctrl-C. tmux can
also drop the last output printed just before such a process exits.
Details: [docs/KNOWN_ISSUES.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/KNOWN_ISSUES.md).

`create(mode=)` or `set_default_mode()` sets the default. Rerun a pane with
`respawn_pane()`: the library tracks the new run (wrap mode also tracks
reruns typed by hand).

## API

| method | |
|---|---|
| `TmuxSession.create(name, env=None, mode="shell", pane_capture=None, status_file=None, rc="~/.bashrc", wait_start=True)` | a new session (replaces one of that name) |
| `TmuxSession.current(env=None, mode="shell", rc="~/.bashrc", wait_start=True)` | adopt the session this process runs in |
| `new_window(name, layout="tiled", env=None, pane_capture=None)` | a window; the next panes go in it |
| `new_pane(name, cmd, mode=None, on_exit=None, on_output=None, env=None, pane_capture=None, cmd_capture=None, wait_start=None)` | a pane running `cmd` |
| `wait_started(target=None, timeout=30)` | wait for `wait_start=False` panes to start (fast bringup, `docs/wrap-mode.md`) |
| `send_keys(cmd, target_pane=None, cmd_capture=None)`, `send_command_before(cmd, window=None)` | type into a pane; set up every new pane |
| `copy_environment(env=None)` | `env` (default `os.environ`) for every later pane |
| `is_running(pane)`, `exit_status(pane)`, `pane_status(pane)`, `status()`, `print_status()` | state: `ExitStatus` (`.code`, `.signal`, `.ok`), `PaneStatus` (`.state`: starting / running / stopped / ok / failed / unknown) |
| `watch_pane(pane, on_exit)`, `wait_for_exit(pane, timeout)` | exit events |
| `stop_pane(pane)`, `stop_all()` | SIGINT, then SIGTERM, then SIGKILL; `stop_all()` in parallel |
| `respawn_pane(pane, cmd=None)` | rerun in place, any mode |
| `capture(target, path)`, `search_output(target, pattern)`, `output_text(pane)`, `output_mark(target)` | output to files; grep them |
| `watch_output(target, pattern, cb)`, `wait_for_output(target, pattern, timeout)`, `expect(target, pattern)` | output events |
| `dashboard()`, `dashboard_pane(cmd)`, `close_dashboard()` | the dashboard |
| `on_close(action)`, `close()`, `bind_close_key(key=None)` | graceful close |
| `kill_session()`, `release(keep_windows=False)` | end, or let go and leave windows up |
| `select_window()`, `select_pane()`, `attach()` | |

A pane's environment is exactly `os.environ` plus `env=` at session,
window and pane level (later wins, `None` removes): nothing from the tmux
server's. Shell panes run `rc` (aliases, prompt), then get their exact
environment back. No size limit. See `docs/environment.md`. `status_file=` writes `status()` as JSON on
every change, for other programs.

`kill_session()` only sends SIGHUP: `stop_all()` or `close()` first for a
clean shutdown.

## Output

```python
sess.new_pane("api", cmd, on_output={r"Traceback": on_crash})  # callback per matching line
m = sess.wait_for_output("main:web", r"port (\d+)", timeout=10)  # what it printed already counts
with sess.expect(pane, r"pong"):                               # watch first, then act
    sess.send_keys("ping", target_pane=pane)

sess.capture("main", "/var/log/app")         # a window's panes to files, from now on
sess.search_output("main", r"ERROR")         # [OutputMatch], later
sess.new_pane("job", cmd, mode="wrap", cmd_capture="job.log")  # the command's output only
```

`target` is a pane, a window, or `None` for the whole session (panes made
later too). `pane_capture=` saves all a pane shows, raw; `cmd_capture=`
(wrap mode) only the command's output, plain text. `since=` is `"start"`
(default), `"now"` or an `output_mark()` everywhere. Matching is on plain
text, colors dropped. Unwatched panes cost nothing. Details:
[docs/output.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/output.md).

## Dashboard

```python
session.dashboard()                                       # window "dash", always first
session.dashboard_pane("htop", title="load", size="30%")  # optional, beside the viewport
```

Left: a sidebar of every pane by window, with running / stopped / failed
counts. Right: the selected pane itself, swapped in (`tmux swap-pane`), so
it is fully interactive. `home` shows the script's stdout and logging.

| key | action |
|---|---|
| `Up` / `Down`, `Enter` | browse (the viewport follows); focus the pane |
| `g` | go to the pane in its own window; `prefix g` comes back |
| `x` / `r` / `c` | stop / rerun the pane / copy its command |
| `X` `X` / `Q` `Q` | stop all / close the session gracefully |
| `[` / `]`, `?`, `q` | resize, help, quit |

Needs Textual. Options: `width=`, `initial=pane`, `mouse=`. More:
[docs/dashboard.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/dashboard.md).

## lte-session

The same dashboard around a command, with no session code of your own:

```bash
lte-session -- pytest -s tests/      # or any script that starts panes
lte-session --keep -- ./bringup.py   # stay up after it ends, to inspect panes
```

- CMD runs in the dashboard's **home** pane: a real terminal, so `print()`,
  `input()`, pdb and Ctrl-C work. Leaving prints its scrollback in your
  terminal; the exit code is CMD's.
- Scripts inside it join the session with `TmuxSession.current()`; their
  panes appear in the sidebar. The pytest plugin does this for you (dash mode).
- Inside tmux it switches your client over and back; with no TTY (CI) it
  follows the transcript.
- `--name N`, `--width N`, `--no-dash` (home pane only; also the fallback
  without Textual).

More: [docs/lte-session.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/lte-session.md).

## Testing with pytest

```python
# conftest.py
pytest_plugins = ["libtmux_easy.testing"]

# test_web.py
from libtmux_easy.testing import Processes

class TestWeb:
    @classmethod
    def setup_class(cls):
        cls.procs = Processes(cls, fail_on={"*": r"Traceback"})   # a match fails the test
        cls.web = cls.procs.start("servers:web", "python3 -m http.server 8000", ready=r"Serving HTTP")

    @classmethod
    def teardown_class(cls):
        cls.procs.close()      # errors unless each exits 0 on SIGINT

    def test_serves(self):
        ...      # self.web.output(), .wait_for(pattern), .stop()
```

pytest makes a new instance of the class for every test, so what the tests
share goes on `cls`. A test fails if a process exits early or prints a
`fail_on` line, even if the test never noticed; the rest of the class is
then skipped. A test can start and stop its own: `with Processes() as
procs:` in the test. Logs go to pytest's temp dir.

| flags | processes run as | you see |
|---|---|---|
| (none) | subprocesses | test output; process output in the logs (`--echo PATTERN`: here too) |
| `--tmux` | panes under `lte-session`, attached | the dashboard; test output in the home pane |
| `--tmux --no-attach` | panes in a detached session, kept on failure | test output; `tmux attach -t ...` |
| `--keep-open` | as `--tmux`, never stopped | inspect after the run |

To test a session script: `procs.build(launch.build)` runs its
`build(session)` function, and `procs.adopt("python3 launch.py")` runs an
unchanged script (libtmux_easy or plain tmux) and adopts the panes it made.
Guide: [docs/testing-scripts.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/testing-scripts.md).

Demos: [demos/pytest](https://github.com/nasa-jpl/libtmux_easy/blob/main/demos/pytest).
Reference: [docs/pytest-plugin.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/pytest-plugin.md).

## Closing a session

tmux has no hook before a session closes, so a graceful close is a command:
`lte-close` runs the close actions, stops every pane (Ctrl-C, then SIGTERM,
SIGKILL) and kills the session, showing its progress in a `close` window.

```python
sess.on_close("./cleanup.sh")                         # shell: runs even if the script died
sess.on_close(lambda s: save_logs(s), when="before")  # Python: while the script runs
sess.close()                                          # or close from the script
```

`prefix Q` (sessions from `create()` or `lte-session`) and the dashboard's
`Q` `Q` run `lte-close`. Every way to stop and close:
[docs/session-close.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/session-close.md).

## Commands

| command | does |
|---|---|
| `lte-run [--tee FILE [-a] [--raw]] [--] CMD` | runs CMD, reporting its pid and exact status to the session (wrap mode); elsewhere just runs it. `--tee` also copies the output to FILE as plain text (`-a` appends) |
| `lte-session [--keep] [--name N] [--no-dash] -- CMD` | runs CMD (e.g. `pytest -s`) in a new dashboard session's home pane: a real terminal, so `input()` and pdb work; panes its tests start appear in the sidebar. Exits with CMD's status |
| `lte-close [-t SESSION]`, `lte-close add -- CMD` | close a session gracefully; add a close action |
| `lte-dash` | the dashboard sidebar (started by `dashboard()`) |
| `lte-key borrow KEY OPTION -- CMD` | bind `prefix KEY` in sessions where OPTION is set, the old binding elsewhere |
| `lte-defaults` | print the starter tmux settings |

`lte-run` keeps the command's terminal: Ctrl-C, Ctrl-Z and stdin work as
usual. See [docs/wrap-mode.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/wrap-mode.md) and
[docs/lte-session.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/lte-session.md).

## tmux defaults

A tmux server with **no config file** gets starter settings: vi copy mode,
`prefix |` / `-` split, `prefix h j k l` move, `prefix C-x` kill-session,
`mouse on`. `LTE_TMUX_DEFAULTS=0` turns them off; a server with a config is
left alone. The library's own keys are borrowed only if unbound, and
`~/.tmux.conf` can move them:

| key | in lte sessions | to change |
|---|---|---|
| `prefix g` | back to the dashboard | `set -g @lte_key_dash G` |
| `prefix Q` | close gracefully | `set -g @lte_key_close ""` (none) |

[docs/tmux-defaults.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/tmux-defaults.md).

## More

- `demos/interactive/`: guided tours, one per feature; start with `01_session.py`.
  `demos/examples/`: plain scripts.
- `docs/`: how it works and why ([index](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/README.md)), and
  [known issues](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/KNOWN_ISSUES.md).
- Tested with libtmux 0.17 to 0.62, tmux 3.3a and 3.4, Python 3.8 to 3.13.
  The `.deb`s need Ubuntu 24.04 or newer; on 22.04, use pipx or pip.

## License

Apache-2.0
