libtmux-easy 0.2.0__py3-none-any.whl

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.
@@ -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))
libtmux_easy/_tmux.py ADDED
@@ -0,0 +1,38 @@
1
+ """Running the tmux binary, for the stdlib-only modules (dash, keys, launch).
2
+ It talks to the server of the calling process ($TMUX), or the default one."""
3
+
4
+ import subprocess
5
+
6
+
7
+ def tmux(*args, check=False):
8
+ """Run 'tmux ARGS'; return its stdout lines. 'check' raises on failure."""
9
+ proc = subprocess.run(["tmux", *args], capture_output=True, text=True)
10
+ if check and proc.returncode:
11
+ raise RuntimeError(f"tmux {args[0]}: {proc.stderr.strip()}")
12
+ return proc.stdout.splitlines()
13
+
14
+
15
+ def option(target, name, scope="-q", run=tmux):
16
+ """An option's value, or ''. 'scope': '-q' session, '-wq' window, '-gq' global."""
17
+ out = run("show-options", f"{scope}v", "-t", target, name) if target else \
18
+ run("show-options", f"{scope}v", name)
19
+ return out[0].strip() if out else ""
20
+
21
+
22
+ # Set by tmux for each pane, or describing the caller's own terminal or
23
+ # session: never copied into a pane's environment. LTE_DIR would point
24
+ # lte-run at another session's socket.
25
+ SKIP_ENV = frozenset(("TMUX", "TMUX_PANE", "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION",
26
+ "COLUMNS", "LINES", "LTE_DIR"))
27
+
28
+
29
+ def pane_env(env):
30
+ """'env' (a mapping, e.g. os.environ) as it may go into a pane: SKIP_ENV
31
+ left out, values as strings."""
32
+ return {k: str(v) for k, v in (env or {}).items() if k not in SKIP_ENV}
33
+
34
+
35
+ def env_args(env):
36
+ """'env' as tmux '-e NAME=VALUE' arguments (new-window, split-window,
37
+ respawn-pane)."""
38
+ return [arg for k, v in pane_env(env).items() for arg in ("-e", f"{k}={v}")]
libtmux_easy/close.py ADDED
@@ -0,0 +1,302 @@
1
+ """lte-close: close a tmux session gracefully, running its close actions.
2
+
3
+ lte-close [-t SESSION] [--timeouts INT,TERM,KILL]
4
+ lte-close add [-t SESSION] [--before] -- COMMAND [ARG...]
5
+ lte-close list [-t SESSION]
6
+ lte-close clear [-t SESSION]
7
+
8
+ Opens a 'close' window in the session and, there, with its progress shown:
9
+
10
+ 1. runs the 'before' actions;
11
+ 2. stops what runs in the panes: the session's script, if it is still
12
+ running, stops its own (and runs its Python on_close callbacks); every
13
+ other pane gets Ctrl-C, then SIGTERM, then SIGKILL;
14
+ 3. runs the 'after' actions (the default for 'add');
15
+ 4. kills the session.
16
+
17
+ Ctrl-C in the close window skips to killing the session. Works on any
18
+ session, with or without libtmux_easy. Sessions made by the library borrow
19
+ prefix Q for it (see KEY); for every session, bind it in ~/.tmux.conf:
20
+
21
+ bind-key Q run-shell -b "lte-close -t '#{session_id}'"
22
+
23
+ Stdlib only. See docs/session-close.md.
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import shlex
30
+ import signal
31
+ import subprocess
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+
36
+ from . import wrap
37
+ from .proc import alive, comm, pane_pids
38
+ from ._tmux import option, tmux as _tmux
39
+ from .dash.control import Control, describe, read_status
40
+ from .dash.options import (HOME_KIND_OPTION, HOME_OPTION, SIDEBAR_OPTION, STATUS_OPTION,
41
+ WINDOW_OPTION)
42
+
43
+ ACTIONS_OPTION = "@lte_on_close" # session option: JSON list of {"cmd", "cwd", "when"}
44
+ STATE_OPTION = "@lte_closing" # set by the script: "stopping", then "done"
45
+ WINDOW_OPTION_CLOSE = "@lte_close_window"
46
+ KEY_OPTION = "@lte_close_key" # session option: set where a borrowed close key applies
47
+ KEY = "Q" # borrowed by create() and lte-session, if unbound
48
+ KEY_ROLE = "close" # ~/.tmux.conf: set -g @lte_key_close KEY ("" for none)
49
+ WINDOW_NAME = "close"
50
+ WHEN = ("before", "after")
51
+
52
+ TIMEOUTS = (10.0, 5.0, 2.0) # SIGINT, SIGTERM, SIGKILL: as stop_pane()
53
+ ACK_TIMEOUT = 2.0 # for the script to take the 'close' command
54
+ SCRIPT_TIMEOUT = 120.0 # for it to finish
55
+ PAUSE = 1.5 # the summary stays up this long before the kill
56
+
57
+
58
+ # ---------- actions (session option) ----------
59
+
60
+ def actions(session, run=_tmux):
61
+ try:
62
+ return json.loads(option(session, ACTIONS_OPTION, run=run) or "[]")
63
+ except ValueError:
64
+ return []
65
+
66
+
67
+ def add_action(session, command, when="after", cwd=None, run=_tmux):
68
+ """Store shell 'command' to run on close, in 'cwd' (default: here)."""
69
+ if when not in WHEN:
70
+ raise ValueError(f"when must be one of {WHEN}, not {when!r}")
71
+ entries = actions(session, run) + [{"cmd": command, "cwd": cwd or os.getcwd(), "when": when}]
72
+ run("set-option", "-q", "-t", session, ACTIONS_OPTION, json.dumps(entries))
73
+
74
+
75
+ def clear_actions(session, run=_tmux):
76
+ run("set-option", "-uq", "-t", session, ACTIONS_OPTION)
77
+
78
+
79
+ def run_actions(entries, when, out=print):
80
+ """Run the shell actions for one phase, in order; output goes to ours."""
81
+ for entry in (e for e in entries if e.get("when", "after") == when):
82
+ out(f"$ {entry['cmd']}")
83
+ try:
84
+ code = subprocess.run(entry["cmd"], shell=True, cwd=entry.get("cwd") or None).returncode
85
+ except OSError as exc:
86
+ out(f" failed: {exc}")
87
+ continue
88
+ if code:
89
+ out(f" exited {code}")
90
+
91
+
92
+ # ---------- the close sequence ----------
93
+
94
+ class Closer:
95
+ def __init__(self, session, timeouts=TIMEOUTS, out=print, run=_tmux):
96
+ self.session = session
97
+ self.timeouts = timeouts
98
+ self.out = out
99
+ self.run = run
100
+ self.name = (run("display-message", "-p", "-t", session, "#{session_name}") or [session])[0]
101
+
102
+ def opt(self, name, target=None):
103
+ return option(target or self.session, name, run=self.run)
104
+
105
+ def __call__(self):
106
+ out = self.out
107
+ out(f"Closing session {self.name}. Ctrl-C: skip to killing it now.\n")
108
+ entries = actions(self.session, self.run)
109
+ try:
110
+ run_actions(entries, "before", out)
111
+ self.stop_script()
112
+ self.stop_panes()
113
+ run_actions(entries, "after", out)
114
+ except KeyboardInterrupt:
115
+ out("\nSkipped the rest.")
116
+ out("\nKilling the session.")
117
+ time.sleep(PAUSE)
118
+ self.run("kill-session", "-t", self.session)
119
+
120
+ # ----- the script -----
121
+
122
+ def stop_script(self):
123
+ """Hand the script its own panes: it knows how they end (wrap mode)
124
+ and runs its Python on_close callbacks."""
125
+ ctl_dir = self.opt(wrap.DIR_OPTION)
126
+ control = Control(os.path.join(ctl_dir, wrap.SOCKET_NAME) if ctl_dir else None)
127
+ if not control.online():
128
+ return
129
+ self.run("set-option", "-uq", "-t", self.session, STATE_OPTION)
130
+ control.send("close", None)
131
+ deadline = time.monotonic() + ACK_TIMEOUT
132
+ while not self.opt(STATE_OPTION) and time.monotonic() < deadline:
133
+ time.sleep(0.05)
134
+ if not self.opt(STATE_OPTION):
135
+ self.out("The session's script did not answer: stopping its panes directly.")
136
+ return
137
+ self.out("The session's script is stopping its panes:")
138
+ seen = {}
139
+ deadline = time.monotonic() + SCRIPT_TIMEOUT
140
+ while time.monotonic() < deadline:
141
+ done = self.opt(STATE_OPTION) == "done"
142
+ path = self.opt(STATUS_OPTION)
143
+ for entry in (read_status(path) if path else None) or ():
144
+ label = describe(entry)[0]
145
+ if seen.get(entry["pane_id"]) != label:
146
+ if entry["pane_id"] in seen or label != "running":
147
+ self.out(f" {entry['name']}: {label}")
148
+ seen[entry["pane_id"]] = label
149
+ if done:
150
+ return
151
+ time.sleep(0.2)
152
+ self.out(" ...gave up waiting for it.")
153
+
154
+ # ----- every other pane -----
155
+
156
+ def _panes(self):
157
+ """pane id -> label, for the panes to stop: not this one, nor the
158
+ dashboard's sidebar or (unless it runs a command) its home pane."""
159
+ skip = {os.environ.get("TMUX_PANE")}
160
+ dash_window = self.opt(WINDOW_OPTION)
161
+ if dash_window:
162
+ skip.add(self.opt(SIDEBAR_OPTION))
163
+ if option(dash_window, HOME_KIND_OPTION, "-wq", run=self.run) != "command":
164
+ skip.add(option(dash_window, HOME_OPTION, "-wq", run=self.run))
165
+ panes = {}
166
+ for line in self.run("list-panes", "-s", "-t", self.session, "-F",
167
+ "#{pane_id} #{pane_pid} #{pane_dead} #{window_name}.#{pane_index}"):
168
+ fields = line.split(None, 3)
169
+ if len(fields) == 4 and fields[0] not in skip and fields[2] != "1":
170
+ panes[fields[0]] = (int(fields[1]), fields[3])
171
+ return panes
172
+
173
+ def stop_panes(self):
174
+ running = {}
175
+ for pane_id, (pid, label) in self._panes().items():
176
+ pids = pane_pids(pid)
177
+ if pids:
178
+ running[pane_id] = (f"{label} ({comm(pids[0]) or pids[0]})", pids)
179
+ if not running:
180
+ self.out("Nothing left running in the panes.")
181
+ return
182
+ self.out(f"Stopping {len(running)} pane(s):")
183
+ for step, timeout in zip(("SIGINT", "SIGTERM", "SIGKILL"), self.timeouts):
184
+ if not running:
185
+ break
186
+ for pane_id, (label, pids) in running.items():
187
+ if step == "SIGINT":
188
+ # A typed Ctrl-C: the foreground job, as the user would.
189
+ self.run("send-keys", "-t", pane_id, "C-c")
190
+ else:
191
+ for pid in pids:
192
+ try:
193
+ os.kill(pid, getattr(signal, step))
194
+ except (ProcessLookupError, PermissionError):
195
+ pass
196
+ self.out(f" {step} to {', '.join(l for l, _ in running.values())}")
197
+ deadline = time.monotonic() + timeout
198
+ while running and time.monotonic() < deadline:
199
+ for pane_id, (label, pids) in list(running.items()):
200
+ if not any(alive(p) for p in pids):
201
+ self.out(f" {label}: stopped by {step}")
202
+ del running[pane_id]
203
+ time.sleep(0.1)
204
+ for label, _ in running.values():
205
+ self.out(f" {label}: still running")
206
+
207
+
208
+ # ---------- the window ----------
209
+
210
+ def self_command():
211
+ """Shell text that runs lte-close from this install, PATH or not."""
212
+ src = Path(__file__).resolve().parents[1]
213
+ return f"env PYTHONPATH={shlex.quote(str(src))} {shlex.quote(sys.executable)} -m libtmux_easy.close"
214
+
215
+
216
+ def key_command():
217
+ """tmux command text for a key binding: close the session it's pressed in."""
218
+ return f"run-shell -b \"{self_command()} -t '#{{session_id}}'\""
219
+
220
+
221
+ def _session_of_caller():
222
+ pane = os.environ.get("TMUX_PANE")
223
+ out = _tmux("display-message", "-p", "-t", pane, "#{session_id}") if pane else []
224
+ return out[0].strip() if out else None
225
+
226
+
227
+ def open_close_window(session, timeouts=TIMEOUTS):
228
+ """Run the close sequence in a new window of 'session' (or the one it
229
+ already has), and show it."""
230
+ window = option(session, WINDOW_OPTION_CLOSE)
231
+ if window and _tmux("list-panes", "-t", window, "-F", "#{pane_id}"):
232
+ _tmux("select-window", "-t", window)
233
+ return window
234
+ command = (f"{self_command()} --here -t {shlex.quote(session)} "
235
+ f"--timeouts {','.join(str(t) for t in timeouts)}")
236
+ out = _tmux("new-window", "-t", f"{session}:", "-n", WINDOW_NAME,
237
+ "-P", "-F", "#{window_id}", command)
238
+ if not out:
239
+ return None
240
+ window = out[0].strip()
241
+ _tmux("set-option", "-q", "-t", session, WINDOW_OPTION_CLOSE, window)
242
+ _tmux("select-window", "-t", window)
243
+ return window
244
+
245
+
246
+ def _timeouts(text):
247
+ values = tuple(float(v) for v in text.split(","))
248
+ if len(values) != 3:
249
+ raise argparse.ArgumentTypeError("three values: SIGINT,SIGTERM,SIGKILL seconds")
250
+ return values
251
+
252
+
253
+ def main(argv=None):
254
+ parser = argparse.ArgumentParser(
255
+ prog="lte-close", description=__doc__.split("\n\n")[0],
256
+ formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split("\n\n", 2)[2])
257
+ parser.add_argument("-t", "--target", help="session (default: the one this runs in)")
258
+ parser.add_argument("--timeouts", type=_timeouts, default=TIMEOUTS, metavar="INT,TERM,KILL",
259
+ help="seconds to wait after each signal (default 10,5,2)")
260
+ parser.add_argument("--here", action="store_true", help=argparse.SUPPRESS)
261
+ parser.add_argument("action", nargs="?", default="close",
262
+ choices=("close", "add", "list", "clear"))
263
+ parser.add_argument("--before", action="store_true",
264
+ help="add: run before the panes are stopped (default: after)")
265
+ parser.add_argument("command", nargs="*", help="add: the command ('--' before one with options)")
266
+ argv = sys.argv[1:] if argv is None else list(argv)
267
+ # A command after '--' is split off by hand: argparse mishandles '--'
268
+ # next to an optional positional.
269
+ after = argv[argv.index("--") + 1:] if "--" in argv else None
270
+ args = parser.parse_args(argv[:argv.index("--")] if after is not None else argv)
271
+ if after is not None:
272
+ args.command = args.command + after
273
+
274
+ session = args.target or _session_of_caller()
275
+ if not session or not _tmux("display-message", "-p", "-t", session, "#{session_id}"):
276
+ sys.stderr.write("lte-close: no such session (run inside tmux, or pass -t)\n")
277
+ return 1
278
+ session = _tmux("display-message", "-p", "-t", session, "#{session_id}")[0].strip()
279
+
280
+ if args.action == "add":
281
+ command = args.command
282
+ if not command:
283
+ parser.error("add needs a command")
284
+ text = command[0] if len(command) == 1 else " ".join(shlex.quote(c) for c in command)
285
+ add_action(session, text, "before" if args.before else "after")
286
+ return 0
287
+ if args.action == "list":
288
+ for entry in actions(session):
289
+ print(f"{entry.get('when', 'after'):6} {entry['cmd']} (in {entry.get('cwd')})")
290
+ return 0
291
+ if args.action == "clear":
292
+ clear_actions(session)
293
+ return 0
294
+ if args.here:
295
+ signal.signal(signal.SIGHUP, signal.SIG_DFL)
296
+ Closer(session, args.timeouts)()
297
+ return 0
298
+ return 0 if open_close_window(session, args.timeouts) else 1
299
+
300
+
301
+ if __name__ == "__main__":
302
+ sys.exit(main())
@@ -0,0 +1,46 @@
1
+ """lte-dash: a status sidebar for a libtmux_easy session.
2
+
3
+ lte-dash --status FILE [--ctl SOCKET]
4
+
5
+ Runs as the left pane of the session's dashboard window (see
6
+ TmuxSession.dashboard()). The right pane is a viewport: selecting a pane in
7
+ the sidebar swaps the real pane into it with 'tmux swap-pane', so it is fully
8
+ interactive. The "home" pane (a 'tail -F' of the script's output) is the
9
+ placeholder that fills the selected pane's slot in its own window while it is
10
+ on show, so every window keeps its layout.
11
+
12
+ Stdlib only, like wrap.py, except app.py (Textual): only cli.main() imports
13
+ it, so the library can use Viewport without Textual.
14
+
15
+ options.py tmux option names holding the dashboard's state
16
+ window.py open_window(), launcher()
17
+ viewport.py Viewport: swap panes in and out
18
+ control.py Control (actions to the script), read_status()
19
+ cli.py lte-dash
20
+ app.py the sidebar UI (Textual)
21
+ keys.py lte-key: borrow a prefix key
22
+ winhook.py a tmux hook that tells the sidebar its window changed
23
+ homelog.py the tee behind TmuxSession.tee_home()
24
+
25
+ A program that is not a TmuxSession can open a dashboard too: 'lte-dash
26
+ --open SESSION ...', or open_window(). It then writes the status file
27
+ itself and answers the control socket's commands.
28
+
29
+ lte-dash --open SESSION --status FILE --ctl SOCKET --home-log FILE
30
+ """
31
+
32
+ from .control import Control, read_status
33
+ from .options import (AUTO, FROM_OPTION, HOME_CMD_OPTION, HOME_KIND_OPTION, HOME_LOG,
34
+ HOME_OPTION, HOME_STATUS_OPTION, INITIAL_OPTION, INSTALL_HINT, KEY,
35
+ KEY_COMMAND, MIN_WIDTH, RUN_DIR_OPTION, SIDEBAR_OPTION, STATUS_OPTION,
36
+ VIEW_OPTION, WIDTH_OPTION, WINDOW_OPTION)
37
+ from .viewport import Viewport
38
+ from .window import launcher, open_window
39
+
40
+ __all__ = [
41
+ "Control", "Viewport", "launcher", "open_window", "read_status",
42
+ "AUTO", "FROM_OPTION", "HOME_CMD_OPTION", "HOME_KIND_OPTION", "HOME_LOG", "HOME_OPTION",
43
+ "HOME_STATUS_OPTION", "INITIAL_OPTION", "INSTALL_HINT", "KEY", "KEY_COMMAND", "MIN_WIDTH",
44
+ "RUN_DIR_OPTION", "SIDEBAR_OPTION", "STATUS_OPTION", "VIEW_OPTION", "WIDTH_OPTION",
45
+ "WINDOW_OPTION",
46
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())