libtmux-easy 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- libtmux_easy-0.2.0/LICENSE +13 -0
- libtmux_easy-0.2.0/NOTICE +10 -0
- libtmux_easy-0.2.0/PKG-INFO +270 -0
- libtmux_easy-0.2.0/README.md +235 -0
- libtmux_easy-0.2.0/pyproject.toml +67 -0
- libtmux_easy-0.2.0/setup.cfg +4 -0
- libtmux_easy-0.2.0/src/libtmux_easy/__init__.py +36 -0
- libtmux_easy-0.2.0/src/libtmux_easy/_tmux.py +38 -0
- libtmux_easy-0.2.0/src/libtmux_easy/close.py +302 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/__init__.py +46 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/__main__.py +5 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/app.py +519 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/cli.py +114 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/control.py +86 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/homelog.py +39 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/keys.py +153 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/options.py +53 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/viewport.py +145 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/window.py +110 -0
- libtmux_easy-0.2.0/src/libtmux_easy/dash/winhook.py +62 -0
- libtmux_easy-0.2.0/src/libtmux_easy/launch.py +351 -0
- libtmux_easy-0.2.0/src/libtmux_easy/output.py +708 -0
- libtmux_easy-0.2.0/src/libtmux_easy/proc.py +135 -0
- libtmux_easy-0.2.0/src/libtmux_easy/starter.py +79 -0
- libtmux_easy-0.2.0/src/libtmux_easy/status.py +79 -0
- libtmux_easy-0.2.0/src/libtmux_easy/testing.py +442 -0
- libtmux_easy-0.2.0/src/libtmux_easy/tmux_lib.py +2514 -0
- libtmux_easy-0.2.0/src/libtmux_easy/watcher.py +277 -0
- libtmux_easy-0.2.0/src/libtmux_easy/wrap.py +513 -0
- libtmux_easy-0.2.0/src/libtmux_easy.egg-info/PKG-INFO +270 -0
- libtmux_easy-0.2.0/src/libtmux_easy.egg-info/SOURCES.txt +43 -0
- libtmux_easy-0.2.0/src/libtmux_easy.egg-info/dependency_links.txt +1 -0
- libtmux_easy-0.2.0/src/libtmux_easy.egg-info/entry_points.txt +7 -0
- libtmux_easy-0.2.0/src/libtmux_easy.egg-info/requires.txt +9 -0
- libtmux_easy-0.2.0/src/libtmux_easy.egg-info/top_level.txt +1 -0
- libtmux_easy-0.2.0/test/test_close.py +176 -0
- libtmux_easy-0.2.0/test/test_dash.py +615 -0
- libtmux_easy-0.2.0/test/test_keys.py +94 -0
- libtmux_easy-0.2.0/test/test_launch.py +172 -0
- libtmux_easy-0.2.0/test/test_output.py +420 -0
- libtmux_easy-0.2.0/test/test_proc.py +59 -0
- libtmux_easy-0.2.0/test/test_session.py +360 -0
- libtmux_easy-0.2.0/test/test_starter.py +62 -0
- libtmux_easy-0.2.0/test/test_testing.py +129 -0
- libtmux_easy-0.2.0/test/test_wrap_mode.py +261 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 California Institute of Technology
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Copyright 2026, by the California Institute of Technology. ALL RIGHTS RESERVED.
|
|
2
|
+
United States Government Sponsorship acknowledged. Any commercial use must be
|
|
3
|
+
negotiated with the Office of Technology Transfer at the California Institute of
|
|
4
|
+
Technology.
|
|
5
|
+
|
|
6
|
+
This software may be subject to U.S. export control laws. By accepting this
|
|
7
|
+
software, the user agrees to comply with all applicable U.S. export laws and
|
|
8
|
+
regulations. User has the responsibility to obtain export licenses, or other
|
|
9
|
+
export authority as may be required before exporting such information to foreign
|
|
10
|
+
countries or providing access to foreign persons.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: libtmux-easy
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: tmux sessions from Python: exact pane exit status, a live dashboard, pytest fixtures
|
|
5
|
+
Author-email: Spencer Diehl <docdiehl@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/nasa-jpl/libtmux_easy
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/nasa-jpl/libtmux_easy/issues
|
|
9
|
+
Project-URL: Repository, https://github.com/nasa-jpl/libtmux_easy
|
|
10
|
+
Keywords: tmux,terminal,multiplexer,session,dashboard,pytest
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: System :: Shells
|
|
22
|
+
Classifier: Topic :: Terminals
|
|
23
|
+
Requires-Python: >=3.8.1
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
License-File: NOTICE
|
|
27
|
+
Requires-Dist: libtmux>=0.17
|
|
28
|
+
Requires-Dist: psutil
|
|
29
|
+
Requires-Dist: packaging
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
32
|
+
Provides-Extra: dash
|
|
33
|
+
Requires-Dist: textual>=1.0; extra == "dash"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# libtmux-easy
|
|
37
|
+
|
|
38
|
+
Build tmux sessions from Python and supervise what runs in them:
|
|
39
|
+
|
|
40
|
+
- **exact exit status** for every pane, and an event the moment one exits;
|
|
41
|
+
- panes stay **interactive**: attach, type, Ctrl-C, pdb;
|
|
42
|
+
- a live **dashboard** of every pane;
|
|
43
|
+
- **output** captured to files, searched, and watched for patterns;
|
|
44
|
+
- a **pytest plugin** that launches the processes a test needs and fails
|
|
45
|
+
the test if one dies.
|
|
46
|
+
|
|
47
|
+
Linux only.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
| you want | install |
|
|
52
|
+
|---|---|
|
|
53
|
+
| library and commands, in a venv | `pip install 'libtmux-easy[dash]'` |
|
|
54
|
+
| the commands only, any distro | `pipx install 'libtmux-easy[dash]'` |
|
|
55
|
+
| Ubuntu system Python (ROS) | both `.deb`s from [Releases](https://github.com/nasa-jpl/libtmux_easy/releases) |
|
|
56
|
+
| to develop | `uv sync` in a checkout |
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install 'libtmux-easy[dash]'
|
|
60
|
+
# not on PyPI yet:
|
|
61
|
+
pip install 'libtmux-easy[dash] @ git+https://github.com/nasa-jpl/libtmux_easy'
|
|
62
|
+
# Ubuntu:
|
|
63
|
+
sudo apt install ./python3-libtmux-easy_0.2.0_all.deb ./libtmux-easy-dash_0.2.0_all.deb
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- Needs tmux. `[dash]` adds Textual for the dashboard; everything else works without it.
|
|
67
|
+
- Wrap-mode panes run `lte-run`: it must be on the panes' PATH, not only the
|
|
68
|
+
script's (the `.deb`, pipx, or `send_command_before("source .venv/bin/activate")`).
|
|
69
|
+
- ROS: a venv made with `python3 -m venv --system-site-packages` sees `rclpy`.
|
|
70
|
+
- More (pipx with the `.deb`, `--user` installs, building the `.deb`s):
|
|
71
|
+
[docs/packaging.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/packaging.md).
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from libtmux_easy import TmuxSession
|
|
77
|
+
|
|
78
|
+
session = TmuxSession.create("my-session")
|
|
79
|
+
session.new_window("work")
|
|
80
|
+
session.new_pane("editor", "nvim")
|
|
81
|
+
server = session.new_pane("server", "python3 -m http.server", mode="wrap")
|
|
82
|
+
|
|
83
|
+
session.new_window("logs", layout="main-horizontal")
|
|
84
|
+
session.new_pane("journal", "journalctl -f")
|
|
85
|
+
|
|
86
|
+
session.wait_for_output(server, r"Serving HTTP", timeout=10)
|
|
87
|
+
session.attach()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Panes are named `window:pane`; anything that takes a pane also takes that
|
|
91
|
+
name, its id (`%3`) or the `Pane`. Each pane's border shows its state
|
|
92
|
+
(`server [running]`, `server [exited 3]`).
|
|
93
|
+
|
|
94
|
+
## Pane modes
|
|
95
|
+
|
|
96
|
+
| `mode=` | the command | exit status | interactive |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| `"shell"` (default) | is typed into bash | from `$?`: a signal death is *inferred* from 128+N | yes |
|
|
99
|
+
| `"wrap"` | is typed as `lte-run <cmd>` | from `lte-run`, exact | yes |
|
|
100
|
+
| `"exec"` | is the pane's process | from tmux, when tmux records it (see below) | no |
|
|
101
|
+
|
|
102
|
+
**Use wrap** to monitor a process and get its exit status: exact, and the
|
|
103
|
+
pane stays a shell you can type into. Shell mode is fine when 128+N is good
|
|
104
|
+
enough.
|
|
105
|
+
|
|
106
|
+
**Avoid exec mode for status.** tmux (3.3a, 3.4) sometimes never collects
|
|
107
|
+
an exec pane's exited process, so the status is lost: `exit_status()`
|
|
108
|
+
returns unknown. It is often lost for a pane stopped with Ctrl-C. tmux can
|
|
109
|
+
also drop the last output printed just before such a process exits.
|
|
110
|
+
Details: [docs/KNOWN_ISSUES.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/KNOWN_ISSUES.md).
|
|
111
|
+
|
|
112
|
+
`create(mode=)` or `set_default_mode()` sets the default. Rerun a pane with
|
|
113
|
+
`respawn_pane()`: the library tracks the new run (wrap mode also tracks
|
|
114
|
+
reruns typed by hand).
|
|
115
|
+
|
|
116
|
+
## API
|
|
117
|
+
|
|
118
|
+
| method | |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `TmuxSession.create(name, env=None, mode="shell", capture=None, status_file=None)` | a new session (replaces one of that name) |
|
|
121
|
+
| `TmuxSession.current(env=None, mode="shell")` | adopt the session this process runs in |
|
|
122
|
+
| `new_window(name, layout="tiled", env=None, capture=None)` | a window; the next panes go in it |
|
|
123
|
+
| `new_pane(name, cmd, mode=None, on_exit=None, on_output=None, env=None, capture=None, tee=None)` | a pane running `cmd` |
|
|
124
|
+
| `send_keys(cmd, target_pane=None)`, `send_command_before(cmd, window=None)` | type into a pane; set up every new pane |
|
|
125
|
+
| `copy_environment(env=None)` | `env` (default `os.environ`) for every later pane |
|
|
126
|
+
| `is_running(pane)`, `exit_status(pane)`, `pane_status(pane)`, `status()`, `print_status()` | state: `ExitStatus` (`.code`, `.signal`, `.ok`), `PaneStatus` (`.state`: running / stopped / ok / failed / unknown) |
|
|
127
|
+
| `watch_pane(pane, on_exit)`, `wait_for_exit(pane, timeout)` | exit events |
|
|
128
|
+
| `stop_pane(pane)`, `stop_all()` | SIGINT, then SIGTERM, then SIGKILL; `stop_all()` in parallel |
|
|
129
|
+
| `respawn_pane(pane, cmd=None)` | rerun in place, any mode |
|
|
130
|
+
| `capture(target, path)`, `search_output(target, pattern)`, `output_text(pane)` | output to files; grep them |
|
|
131
|
+
| `watch_output(target, pattern, cb)`, `wait_for_output(target, pattern, timeout)`, `expect(target, pattern)` | output events |
|
|
132
|
+
| `dashboard()`, `dashboard_pane(cmd)`, `close_dashboard()` | the dashboard |
|
|
133
|
+
| `on_close(action)`, `close()`, `bind_close_key(key=None)` | graceful close |
|
|
134
|
+
| `kill_session()`, `release(keep_windows=False)` | end, or let go and leave windows up |
|
|
135
|
+
| `select_window()`, `select_pane()`, `attach()` | |
|
|
136
|
+
|
|
137
|
+
`env=` (e.g. `os.environ`) works at session, window and pane level, later
|
|
138
|
+
levels winning: panes otherwise get the tmux *server's* environment, which
|
|
139
|
+
may predate an activated venv. `status_file=` writes `status()` as JSON on
|
|
140
|
+
every change, for other programs.
|
|
141
|
+
|
|
142
|
+
`kill_session()` only sends SIGHUP: `stop_all()` or `close()` first for a
|
|
143
|
+
clean shutdown.
|
|
144
|
+
|
|
145
|
+
## Output
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
sess.new_pane("api", cmd, on_output={r"Traceback": on_crash}) # callback per matching line
|
|
149
|
+
m = sess.wait_for_output("main:web", r"port (\d+)", timeout=10) # what it printed already counts
|
|
150
|
+
with sess.expect(pane, r"pong"): # watch first, then act
|
|
151
|
+
sess.send_keys("ping", target_pane=pane)
|
|
152
|
+
|
|
153
|
+
cap = sess.capture("main", "/var/log/app") # a window's panes to files, from now on
|
|
154
|
+
sess.search_output("main", r"ERROR") # [OutputMatch], later
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`target` is a pane, a window, or `None` for the whole session (panes made
|
|
158
|
+
later too). tmux pipes each captured pane to a file (`pipe-pane`); matching
|
|
159
|
+
is on plain text, colors dropped. Unwatched panes cost nothing. Details:
|
|
160
|
+
[docs/output-match.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/output-match.md).
|
|
161
|
+
|
|
162
|
+
## Dashboard
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
session.dashboard() # window "dash", always first
|
|
166
|
+
session.dashboard_pane("htop", title="load", size="30%") # optional, beside the viewport
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Left: a sidebar of every pane by window, with running / stopped / failed
|
|
170
|
+
counts. Right: the selected pane itself, swapped in (`tmux swap-pane`), so
|
|
171
|
+
it is fully interactive. `home` shows the script's stdout and logging.
|
|
172
|
+
|
|
173
|
+
| key | action |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `Up` / `Down`, `Enter` | browse (the viewport follows); focus the pane |
|
|
176
|
+
| `g` | go to the pane in its own window; `prefix g` comes back |
|
|
177
|
+
| `x` / `r` / `c` | stop / rerun the pane / copy its command |
|
|
178
|
+
| `X` `X` / `Q` `Q` | stop all / close the session gracefully |
|
|
179
|
+
| `[` / `]`, `?`, `q` | resize, help, quit |
|
|
180
|
+
|
|
181
|
+
Needs Textual. Options: `width=`, `initial=pane`, `mouse=`. More:
|
|
182
|
+
[docs/dashboard.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/dashboard.md).
|
|
183
|
+
|
|
184
|
+
## Testing with pytest
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
# conftest.py
|
|
188
|
+
pytest_plugins = ["libtmux_easy.testing"]
|
|
189
|
+
|
|
190
|
+
# test_web.py
|
|
191
|
+
PROCESSES = {"servers:web": "python3 -m http.server 8000"}
|
|
192
|
+
READY = {"servers:web": r"Serving HTTP"} # optional: wait for it first
|
|
193
|
+
FAIL_ON = {"*": r"Traceback"} # optional: a match fails the test
|
|
194
|
+
|
|
195
|
+
def test_serves(processes):
|
|
196
|
+
... # processes.output("servers:web"), .wait_for(name, pattern), .check()
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`processes` (module scope) starts them and waits for `READY`. A test fails
|
|
200
|
+
if a process exits early or prints a `FAIL_ON` line, even if the test never
|
|
201
|
+
noticed. Teardown stops them, and errors unless each exited 0 on SIGINT.
|
|
202
|
+
|
|
203
|
+
| `--lte-mode` | processes run as | after the run |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| `shell` | subprocesses, output prefixed `[name]` | stopped |
|
|
206
|
+
| `tmux` | wrap panes in a detached session | kept on failure (`--lte-keep`) |
|
|
207
|
+
| `dash` | panes in `lte-session`'s session and dashboard | kept with `lte-session --keep` |
|
|
208
|
+
|
|
209
|
+
Default: dash under lte-session, else tmux, else shell. Fixtures to
|
|
210
|
+
override, and `tmux_session` for a test's own session:
|
|
211
|
+
[docs/pytest-plugin.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/pytest-plugin.md).
|
|
212
|
+
|
|
213
|
+
## Closing a session
|
|
214
|
+
|
|
215
|
+
tmux has no hook before a session closes, so a graceful close is a command:
|
|
216
|
+
`lte-close` runs the close actions, stops every pane (Ctrl-C, then SIGTERM,
|
|
217
|
+
SIGKILL) and kills the session, showing its progress in a `close` window.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
sess.on_close("./cleanup.sh") # shell: runs even if the script died
|
|
221
|
+
sess.on_close(lambda s: save_logs(s), when="before") # Python: while the script runs
|
|
222
|
+
sess.close() # or close from the script
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`prefix Q` (sessions from `create()` or `lte-session`) and the dashboard's
|
|
226
|
+
`Q` `Q` run `lte-close`. Every way to stop and close:
|
|
227
|
+
[docs/session-close.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/session-close.md).
|
|
228
|
+
|
|
229
|
+
## Commands
|
|
230
|
+
|
|
231
|
+
| command | does |
|
|
232
|
+
|---|---|
|
|
233
|
+
| `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) |
|
|
234
|
+
| `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 |
|
|
235
|
+
| `lte-close [-t SESSION]`, `lte-close add -- CMD` | close a session gracefully; add a close action |
|
|
236
|
+
| `lte-dash` | the dashboard sidebar (started by `dashboard()`) |
|
|
237
|
+
| `lte-key borrow KEY OPTION -- CMD` | bind `prefix KEY` in sessions where OPTION is set, the old binding elsewhere |
|
|
238
|
+
| `lte-defaults` | print the starter tmux settings |
|
|
239
|
+
|
|
240
|
+
`lte-run` keeps the command's terminal: Ctrl-C, Ctrl-Z and stdin work as
|
|
241
|
+
usual. See [docs/wrap-mode.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/wrap-mode.md) and
|
|
242
|
+
[docs/lte-session.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/lte-session.md).
|
|
243
|
+
|
|
244
|
+
## tmux defaults
|
|
245
|
+
|
|
246
|
+
A tmux server with **no config file** gets starter settings: vi copy mode,
|
|
247
|
+
`prefix |` / `-` split, `prefix h j k l` move, `prefix C-x` kill-session,
|
|
248
|
+
`mouse on`. `LTE_TMUX_DEFAULTS=0` turns them off; a server with a config is
|
|
249
|
+
left alone. The library's own keys are borrowed only if unbound, and
|
|
250
|
+
`~/.tmux.conf` can move them:
|
|
251
|
+
|
|
252
|
+
| key | in lte sessions | to change |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| `prefix g` | back to the dashboard | `set -g @lte_key_dash G` |
|
|
255
|
+
| `prefix Q` | close gracefully | `set -g @lte_key_close ""` (none) |
|
|
256
|
+
|
|
257
|
+
[docs/tmux-defaults.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/tmux-defaults.md).
|
|
258
|
+
|
|
259
|
+
## More
|
|
260
|
+
|
|
261
|
+
- `demos/interactive/`: guided tours, one per feature; start with `01_session.py`.
|
|
262
|
+
`demos/examples/`: plain scripts.
|
|
263
|
+
- `docs/`: how it works and why ([index](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/README.md)), and
|
|
264
|
+
[known issues](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/KNOWN_ISSUES.md).
|
|
265
|
+
- Tested with libtmux 0.17 to 0.62, tmux 3.3a and 3.4, Python 3.8 to 3.13.
|
|
266
|
+
The `.deb`s need Ubuntu 24.04 or newer; on 22.04, use pipx or pip.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
Apache-2.0
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# libtmux-easy
|
|
2
|
+
|
|
3
|
+
Build tmux sessions from Python and supervise what runs in them:
|
|
4
|
+
|
|
5
|
+
- **exact exit status** for every pane, and an event the moment one exits;
|
|
6
|
+
- panes stay **interactive**: attach, type, Ctrl-C, pdb;
|
|
7
|
+
- a live **dashboard** of every pane;
|
|
8
|
+
- **output** captured to files, searched, and watched for patterns;
|
|
9
|
+
- a **pytest plugin** that launches the processes a test needs and fails
|
|
10
|
+
the test if one dies.
|
|
11
|
+
|
|
12
|
+
Linux only.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
| you want | install |
|
|
17
|
+
|---|---|
|
|
18
|
+
| library and commands, in a venv | `pip install 'libtmux-easy[dash]'` |
|
|
19
|
+
| the commands only, any distro | `pipx install 'libtmux-easy[dash]'` |
|
|
20
|
+
| Ubuntu system Python (ROS) | both `.deb`s from [Releases](https://github.com/nasa-jpl/libtmux_easy/releases) |
|
|
21
|
+
| to develop | `uv sync` in a checkout |
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install 'libtmux-easy[dash]'
|
|
25
|
+
# not on PyPI yet:
|
|
26
|
+
pip install 'libtmux-easy[dash] @ git+https://github.com/nasa-jpl/libtmux_easy'
|
|
27
|
+
# Ubuntu:
|
|
28
|
+
sudo apt install ./python3-libtmux-easy_0.2.0_all.deb ./libtmux-easy-dash_0.2.0_all.deb
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- Needs tmux. `[dash]` adds Textual for the dashboard; everything else works without it.
|
|
32
|
+
- Wrap-mode panes run `lte-run`: it must be on the panes' PATH, not only the
|
|
33
|
+
script's (the `.deb`, pipx, or `send_command_before("source .venv/bin/activate")`).
|
|
34
|
+
- ROS: a venv made with `python3 -m venv --system-site-packages` sees `rclpy`.
|
|
35
|
+
- More (pipx with the `.deb`, `--user` installs, building the `.deb`s):
|
|
36
|
+
[docs/packaging.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/packaging.md).
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from libtmux_easy import TmuxSession
|
|
42
|
+
|
|
43
|
+
session = TmuxSession.create("my-session")
|
|
44
|
+
session.new_window("work")
|
|
45
|
+
session.new_pane("editor", "nvim")
|
|
46
|
+
server = session.new_pane("server", "python3 -m http.server", mode="wrap")
|
|
47
|
+
|
|
48
|
+
session.new_window("logs", layout="main-horizontal")
|
|
49
|
+
session.new_pane("journal", "journalctl -f")
|
|
50
|
+
|
|
51
|
+
session.wait_for_output(server, r"Serving HTTP", timeout=10)
|
|
52
|
+
session.attach()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Panes are named `window:pane`; anything that takes a pane also takes that
|
|
56
|
+
name, its id (`%3`) or the `Pane`. Each pane's border shows its state
|
|
57
|
+
(`server [running]`, `server [exited 3]`).
|
|
58
|
+
|
|
59
|
+
## Pane modes
|
|
60
|
+
|
|
61
|
+
| `mode=` | the command | exit status | interactive |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| `"shell"` (default) | is typed into bash | from `$?`: a signal death is *inferred* from 128+N | yes |
|
|
64
|
+
| `"wrap"` | is typed as `lte-run <cmd>` | from `lte-run`, exact | yes |
|
|
65
|
+
| `"exec"` | is the pane's process | from tmux, when tmux records it (see below) | no |
|
|
66
|
+
|
|
67
|
+
**Use wrap** to monitor a process and get its exit status: exact, and the
|
|
68
|
+
pane stays a shell you can type into. Shell mode is fine when 128+N is good
|
|
69
|
+
enough.
|
|
70
|
+
|
|
71
|
+
**Avoid exec mode for status.** tmux (3.3a, 3.4) sometimes never collects
|
|
72
|
+
an exec pane's exited process, so the status is lost: `exit_status()`
|
|
73
|
+
returns unknown. It is often lost for a pane stopped with Ctrl-C. tmux can
|
|
74
|
+
also drop the last output printed just before such a process exits.
|
|
75
|
+
Details: [docs/KNOWN_ISSUES.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/KNOWN_ISSUES.md).
|
|
76
|
+
|
|
77
|
+
`create(mode=)` or `set_default_mode()` sets the default. Rerun a pane with
|
|
78
|
+
`respawn_pane()`: the library tracks the new run (wrap mode also tracks
|
|
79
|
+
reruns typed by hand).
|
|
80
|
+
|
|
81
|
+
## API
|
|
82
|
+
|
|
83
|
+
| method | |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `TmuxSession.create(name, env=None, mode="shell", capture=None, status_file=None)` | a new session (replaces one of that name) |
|
|
86
|
+
| `TmuxSession.current(env=None, mode="shell")` | adopt the session this process runs in |
|
|
87
|
+
| `new_window(name, layout="tiled", env=None, capture=None)` | a window; the next panes go in it |
|
|
88
|
+
| `new_pane(name, cmd, mode=None, on_exit=None, on_output=None, env=None, capture=None, tee=None)` | a pane running `cmd` |
|
|
89
|
+
| `send_keys(cmd, target_pane=None)`, `send_command_before(cmd, window=None)` | type into a pane; set up every new pane |
|
|
90
|
+
| `copy_environment(env=None)` | `env` (default `os.environ`) for every later pane |
|
|
91
|
+
| `is_running(pane)`, `exit_status(pane)`, `pane_status(pane)`, `status()`, `print_status()` | state: `ExitStatus` (`.code`, `.signal`, `.ok`), `PaneStatus` (`.state`: running / stopped / ok / failed / unknown) |
|
|
92
|
+
| `watch_pane(pane, on_exit)`, `wait_for_exit(pane, timeout)` | exit events |
|
|
93
|
+
| `stop_pane(pane)`, `stop_all()` | SIGINT, then SIGTERM, then SIGKILL; `stop_all()` in parallel |
|
|
94
|
+
| `respawn_pane(pane, cmd=None)` | rerun in place, any mode |
|
|
95
|
+
| `capture(target, path)`, `search_output(target, pattern)`, `output_text(pane)` | output to files; grep them |
|
|
96
|
+
| `watch_output(target, pattern, cb)`, `wait_for_output(target, pattern, timeout)`, `expect(target, pattern)` | output events |
|
|
97
|
+
| `dashboard()`, `dashboard_pane(cmd)`, `close_dashboard()` | the dashboard |
|
|
98
|
+
| `on_close(action)`, `close()`, `bind_close_key(key=None)` | graceful close |
|
|
99
|
+
| `kill_session()`, `release(keep_windows=False)` | end, or let go and leave windows up |
|
|
100
|
+
| `select_window()`, `select_pane()`, `attach()` | |
|
|
101
|
+
|
|
102
|
+
`env=` (e.g. `os.environ`) works at session, window and pane level, later
|
|
103
|
+
levels winning: panes otherwise get the tmux *server's* environment, which
|
|
104
|
+
may predate an activated venv. `status_file=` writes `status()` as JSON on
|
|
105
|
+
every change, for other programs.
|
|
106
|
+
|
|
107
|
+
`kill_session()` only sends SIGHUP: `stop_all()` or `close()` first for a
|
|
108
|
+
clean shutdown.
|
|
109
|
+
|
|
110
|
+
## Output
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
sess.new_pane("api", cmd, on_output={r"Traceback": on_crash}) # callback per matching line
|
|
114
|
+
m = sess.wait_for_output("main:web", r"port (\d+)", timeout=10) # what it printed already counts
|
|
115
|
+
with sess.expect(pane, r"pong"): # watch first, then act
|
|
116
|
+
sess.send_keys("ping", target_pane=pane)
|
|
117
|
+
|
|
118
|
+
cap = sess.capture("main", "/var/log/app") # a window's panes to files, from now on
|
|
119
|
+
sess.search_output("main", r"ERROR") # [OutputMatch], later
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`target` is a pane, a window, or `None` for the whole session (panes made
|
|
123
|
+
later too). tmux pipes each captured pane to a file (`pipe-pane`); matching
|
|
124
|
+
is on plain text, colors dropped. Unwatched panes cost nothing. Details:
|
|
125
|
+
[docs/output-match.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/output-match.md).
|
|
126
|
+
|
|
127
|
+
## Dashboard
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
session.dashboard() # window "dash", always first
|
|
131
|
+
session.dashboard_pane("htop", title="load", size="30%") # optional, beside the viewport
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Left: a sidebar of every pane by window, with running / stopped / failed
|
|
135
|
+
counts. Right: the selected pane itself, swapped in (`tmux swap-pane`), so
|
|
136
|
+
it is fully interactive. `home` shows the script's stdout and logging.
|
|
137
|
+
|
|
138
|
+
| key | action |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `Up` / `Down`, `Enter` | browse (the viewport follows); focus the pane |
|
|
141
|
+
| `g` | go to the pane in its own window; `prefix g` comes back |
|
|
142
|
+
| `x` / `r` / `c` | stop / rerun the pane / copy its command |
|
|
143
|
+
| `X` `X` / `Q` `Q` | stop all / close the session gracefully |
|
|
144
|
+
| `[` / `]`, `?`, `q` | resize, help, quit |
|
|
145
|
+
|
|
146
|
+
Needs Textual. Options: `width=`, `initial=pane`, `mouse=`. More:
|
|
147
|
+
[docs/dashboard.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/dashboard.md).
|
|
148
|
+
|
|
149
|
+
## Testing with pytest
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# conftest.py
|
|
153
|
+
pytest_plugins = ["libtmux_easy.testing"]
|
|
154
|
+
|
|
155
|
+
# test_web.py
|
|
156
|
+
PROCESSES = {"servers:web": "python3 -m http.server 8000"}
|
|
157
|
+
READY = {"servers:web": r"Serving HTTP"} # optional: wait for it first
|
|
158
|
+
FAIL_ON = {"*": r"Traceback"} # optional: a match fails the test
|
|
159
|
+
|
|
160
|
+
def test_serves(processes):
|
|
161
|
+
... # processes.output("servers:web"), .wait_for(name, pattern), .check()
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`processes` (module scope) starts them and waits for `READY`. A test fails
|
|
165
|
+
if a process exits early or prints a `FAIL_ON` line, even if the test never
|
|
166
|
+
noticed. Teardown stops them, and errors unless each exited 0 on SIGINT.
|
|
167
|
+
|
|
168
|
+
| `--lte-mode` | processes run as | after the run |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `shell` | subprocesses, output prefixed `[name]` | stopped |
|
|
171
|
+
| `tmux` | wrap panes in a detached session | kept on failure (`--lte-keep`) |
|
|
172
|
+
| `dash` | panes in `lte-session`'s session and dashboard | kept with `lte-session --keep` |
|
|
173
|
+
|
|
174
|
+
Default: dash under lte-session, else tmux, else shell. Fixtures to
|
|
175
|
+
override, and `tmux_session` for a test's own session:
|
|
176
|
+
[docs/pytest-plugin.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/pytest-plugin.md).
|
|
177
|
+
|
|
178
|
+
## Closing a session
|
|
179
|
+
|
|
180
|
+
tmux has no hook before a session closes, so a graceful close is a command:
|
|
181
|
+
`lte-close` runs the close actions, stops every pane (Ctrl-C, then SIGTERM,
|
|
182
|
+
SIGKILL) and kills the session, showing its progress in a `close` window.
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
sess.on_close("./cleanup.sh") # shell: runs even if the script died
|
|
186
|
+
sess.on_close(lambda s: save_logs(s), when="before") # Python: while the script runs
|
|
187
|
+
sess.close() # or close from the script
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`prefix Q` (sessions from `create()` or `lte-session`) and the dashboard's
|
|
191
|
+
`Q` `Q` run `lte-close`. Every way to stop and close:
|
|
192
|
+
[docs/session-close.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/session-close.md).
|
|
193
|
+
|
|
194
|
+
## Commands
|
|
195
|
+
|
|
196
|
+
| command | does |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `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) |
|
|
199
|
+
| `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 |
|
|
200
|
+
| `lte-close [-t SESSION]`, `lte-close add -- CMD` | close a session gracefully; add a close action |
|
|
201
|
+
| `lte-dash` | the dashboard sidebar (started by `dashboard()`) |
|
|
202
|
+
| `lte-key borrow KEY OPTION -- CMD` | bind `prefix KEY` in sessions where OPTION is set, the old binding elsewhere |
|
|
203
|
+
| `lte-defaults` | print the starter tmux settings |
|
|
204
|
+
|
|
205
|
+
`lte-run` keeps the command's terminal: Ctrl-C, Ctrl-Z and stdin work as
|
|
206
|
+
usual. See [docs/wrap-mode.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/wrap-mode.md) and
|
|
207
|
+
[docs/lte-session.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/lte-session.md).
|
|
208
|
+
|
|
209
|
+
## tmux defaults
|
|
210
|
+
|
|
211
|
+
A tmux server with **no config file** gets starter settings: vi copy mode,
|
|
212
|
+
`prefix |` / `-` split, `prefix h j k l` move, `prefix C-x` kill-session,
|
|
213
|
+
`mouse on`. `LTE_TMUX_DEFAULTS=0` turns them off; a server with a config is
|
|
214
|
+
left alone. The library's own keys are borrowed only if unbound, and
|
|
215
|
+
`~/.tmux.conf` can move them:
|
|
216
|
+
|
|
217
|
+
| key | in lte sessions | to change |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| `prefix g` | back to the dashboard | `set -g @lte_key_dash G` |
|
|
220
|
+
| `prefix Q` | close gracefully | `set -g @lte_key_close ""` (none) |
|
|
221
|
+
|
|
222
|
+
[docs/tmux-defaults.md](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/tmux-defaults.md).
|
|
223
|
+
|
|
224
|
+
## More
|
|
225
|
+
|
|
226
|
+
- `demos/interactive/`: guided tours, one per feature; start with `01_session.py`.
|
|
227
|
+
`demos/examples/`: plain scripts.
|
|
228
|
+
- `docs/`: how it works and why ([index](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/README.md)), and
|
|
229
|
+
[known issues](https://github.com/nasa-jpl/libtmux_easy/blob/main/docs/KNOWN_ISSUES.md).
|
|
230
|
+
- Tested with libtmux 0.17 to 0.62, tmux 3.3a and 3.4, Python 3.8 to 3.13.
|
|
231
|
+
The `.deb`s need Ubuntu 24.04 or newer; on 22.04, use pipx or pip.
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
Apache-2.0
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "libtmux-easy"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "tmux sessions from Python: exact pane exit status, a live dashboard, pytest fixtures"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "Apache-2.0"}
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Spencer Diehl", email = "docdiehl@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
keywords = ["tmux", "terminal", "multiplexer", "session", "dashboard", "pytest"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: Apache Software License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.8",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: System :: Shells",
|
|
23
|
+
"Topic :: Terminals",
|
|
24
|
+
]
|
|
25
|
+
requires-python = ">=3.8.1"
|
|
26
|
+
dependencies = [
|
|
27
|
+
"libtmux>=0.17",
|
|
28
|
+
"psutil",
|
|
29
|
+
"packaging",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
test = [
|
|
34
|
+
"pytest>=7.0",
|
|
35
|
+
]
|
|
36
|
+
# lte-dash, the dashboard sidebar. Ubuntu's python3-textual is too old, so the
|
|
37
|
+
# libtmux-easy-dash .deb bundles this from uv.lock (debian/dash-requirements.txt).
|
|
38
|
+
dash = [
|
|
39
|
+
"textual>=1.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# 'uv sync' installs this group by default: a development environment with
|
|
43
|
+
# the dashboard and the tests. Not part of the package (pip, the .deb).
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = ["libtmux-easy[dash,test]"]
|
|
46
|
+
|
|
47
|
+
[project.scripts]
|
|
48
|
+
lte-run = "libtmux_easy.wrap:main"
|
|
49
|
+
lte-dash = "libtmux_easy.dash.cli:main"
|
|
50
|
+
lte-session = "libtmux_easy.launch:main"
|
|
51
|
+
lte-key = "libtmux_easy.dash.keys:main"
|
|
52
|
+
lte-close = "libtmux_easy.close:main"
|
|
53
|
+
lte-defaults = "libtmux_easy.starter:main"
|
|
54
|
+
|
|
55
|
+
# setuptools rather than a newer backend: it is packaged for Ubuntu 24.04, so
|
|
56
|
+
# the .deb (debian/) builds from this same file with apt-only dependencies.
|
|
57
|
+
[build-system]
|
|
58
|
+
requires = ["setuptools>=61"]
|
|
59
|
+
build-backend = "setuptools.build_meta"
|
|
60
|
+
|
|
61
|
+
[tool.setuptools.packages.find]
|
|
62
|
+
where = ["src"]
|
|
63
|
+
|
|
64
|
+
[project.urls]
|
|
65
|
+
"Homepage" = "https://github.com/nasa-jpl/libtmux_easy"
|
|
66
|
+
"Bug Tracker" = "https://github.com/nasa-jpl/libtmux_easy/issues"
|
|
67
|
+
"Repository" = "https://github.com/nasa-jpl/libtmux_easy"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The names below are loaded on first use, not at import. The lte-run console
|
|
2
|
+
# script imports libtmux_easy.wrap, which runs this file first; importing
|
|
3
|
+
# tmux_lib here would drag libtmux and psutil into every wrapped pane (about
|
|
4
|
+
# 4x the startup time and +7 MB each) for a script that uses neither.
|
|
5
|
+
_EXPORTS = (
|
|
6
|
+
"TmuxSession",
|
|
7
|
+
"session_exists",
|
|
8
|
+
"set_server",
|
|
9
|
+
"tmux_command",
|
|
10
|
+
"DeprecatedError",
|
|
11
|
+
"libtmux_version",
|
|
12
|
+
"ExitStatus",
|
|
13
|
+
"PaneStatus",
|
|
14
|
+
"PANE_MODE_SHELL",
|
|
15
|
+
"PANE_MODE_EXEC",
|
|
16
|
+
"PANE_MODE_WRAP",
|
|
17
|
+
"Capture",
|
|
18
|
+
"OutputExpect",
|
|
19
|
+
"OutputMatch",
|
|
20
|
+
"OutputWatch",
|
|
21
|
+
"PaneExited",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = list(_EXPORTS)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def __getattr__(name):
|
|
28
|
+
if name in _EXPORTS:
|
|
29
|
+
from . import tmux_lib
|
|
30
|
+
|
|
31
|
+
return getattr(tmux_lib, name)
|
|
32
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def __dir__():
|
|
36
|
+
return sorted(set(globals()) | set(_EXPORTS))
|