Launching scripts in tmux
I was asked to help write a script to automate launching 16 scripts in a tmux session.
The researcher who wanted this was currently running their scripts by hand and organising them into a grid of panes, however they also said they were happy to run the scripts in individual windows if that was easier. I wrote the script to support either.
Version 1 - Bash
I wrote a quick version in Bash, with the intention of adding support for command-line options and help messages etc. however it transpired that the person who wanted this knew Python but not Bash so, in order that they should be able to maintain it themselves, I rewrote it in Python. This is the bash version.
#!/bin/bash
#############
# Variables #
#############
# Configure these as necessary
SESSION_NAME="my_scripts" # Name of the tmux sessions to look for
SCRIPT_PATH="${HOME}/python/scripts" # Location of the scripts to run
SCRIPT_PATTERN="*.py" # All files matching this pattern will be launched
# Valid modes are:
# GRID - arrange the tmux windows in a grid, size determined by number of
# scripts found
# SEPARATE - each script will be launched in a separate window
MODE="GRID"
LAUNCH_COMMAND="/usr/bin/env python" # Command used to launch each script
#############
# Functions #
#############
launch_scripts() {
# We will have a blank window on the first time through, so need
# a flag to know whether to launch a new one
FIRST=1
for file in $( eval "ls ${SCRIPT_PATH}/${SCRIPT_PATTERN}" )
do
case "${MODE}" in
SEPARATE)
if [[ ${FIRST} -ne 1 ]]
then
tmux new-window -n "$( basename "${file}" )" -t ${SESSION_NAME}:
else
# Flip the flag on first run - skip creating a new window
# first time
FIRST=0
fi
;;
GRID)
# Split the window into a grid on the first time through
if [[ ${FIRST} -eq 1 ]]
then
# Start at 2 as we already have one window when we start
for i in $( seq 2 ${NO_OF_FILES} )
do
tmux split-window -d -t ${SESSION_NAME}:
tmux select-layout -t ${SESSION_NAME}: tiled
done
FIRST=0 # Only create new windows first time through
else # On subsequent times through, just select the next
tmux select-pane -t ${SESSION_NAME}:.+
fi
;;
*)
echo "Unknown mode: ${MODE}" >&2
exit 1
;;
esac
# Actually launch the script
# -l = literal, to stop anything special (like "Enter") getting
# interpreted by tmux
tmux send-keys -t ${SESSION_NAME} -l "${LAUNCH_COMMAND} \"${file}\""
tmux send-keys -t ${SESSION_NAME} "Enter" # Press return
done
}
###########################
# Main part of the script #
###########################
# Does the tmux session exist?
if ! tmux has-session -t ${SESSION_NAME}
then
# No...then we need to start it
echo "Restarting scripts..."
# How many files?
NO_OF_FILES=$( eval "ls \"${SCRIPT_PATH}\"/${SCRIPT_PATTERN} | wc -l" )
# -d to start the session detached
tmux new-session -d -s ${SESSION_NAME}
# Run the scripts
launch_scripts
fi
Version 2 - Python
This is the Python version, with command-line options, help messages etc.:
#!/usr/bin/env python3
import argparse
import logging
from pathlib import Path
import subprocess
logger = logging.getLogger(__name__)
class SeperateWindows(object):
"""Class to launch processes in seperate windows in tmux."""
def __init__(self, files_to_launch, tmux_session_name, launcher_cmd=None):
"""
Initialise the instance.
Args:
files_to_launch: an iterable containing the
full paths to files to launch
tmux_session_name: the tumux session to look for or
start
laucher_cmd: an iterable containing arguments
that form a custom command to prepend to
the files when running.
"""
self._files_to_launch = files_to_launch
self._launcher_cmd = launcher_cmd
self._tmux_session_name = tmux_session_name
def _setup_tmux(self):
"""
Setup tmux for the launch.
"""
# Create a window for each script
first = True
logger.debug("Creating windows for each script")
for script in self._files_to_launch:
if first:
# Just rename the first (already existing) window
# first time around
subprocess.run(
[
"tmux", "rename-window", "-t",
self._tmux_session_name, script.name,
],
check=True,
)
first = False
else:
subprocess.run(
[
"tmux", "new-window", "-t",
self._tmux_session_name, "-n", script.name,
],
check=True,
)
logger.debug("Created window for %s", script)
# Select window 0 as the first window
subprocess.run(
[
"tmux", "select-window", "-t",
"%s:0" % self._tmux_session_name,
],
check=True,
)
def _select_next_window(self):
"""
Select the next window ready to launch the next script.
"""
logger.debug("Selecting next window.")
subprocess.run(
[
"tmux", "select-window", "-t",
"%s:+" % self._tmux_session_name,
],
check=True,
)
def launch(self):
"""
Launch the tmux and run each script
"""
if self.tmux_session_exists():
logger.error("Tried to launch new tmux session but one exists")
raise RuntimeError("tmux session already exists!")
# Create the new session
# -d to start the session detached
subprocess.run(
["tmux", "new-session", "-d", "-s",
self._tmux_session_name],
check=True
)
logger.debug("Launched new tmux session")
# Do any initial setup required
logger.debug("Setting up tmux")
self._setup_tmux()
# Launch each script
first = True
if self._launcher_cmd:
prefix = "%s " % self._launcher_cmd
else:
prefix = ""
logger.debug("Scripts will be launched with %s", prefix)
# Count how many scripts have been launched
for script in self._files_to_launch:
logger.debug("Running: %s", script)
if not first:
# After the first script, we need to
# select the next window
self._select_next_window()
else:
first = False # No longer the first
# '-l' literal, to stop anything special (like
# "Enter") getting interpreted by tmux.
subprocess.run(
[
"tmux", "send-keys", "-t",
self._tmux_session_name, "-l",
"%s\"%s\"" % (prefix, script),
],
check=True,
)
# Send a return to launch the script
subprocess.run(
[
"tmux", "send-keys", "-t",
self._tmux_session_name, "Enter"
],
check=True,
)
def tmux_session_exists(self):
"""
Check if the tmux session exists.
returns: True if it does, False otherwise
"""
result = subprocess.run(
["tmux", "has-session", "-t", self._tmux_session_name],
stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
logger.debug(
"tmux has-session returned status %d with stdout '%s' and"
" stderr '%s'",
result.returncode, result.stdout, result.stderr,
)
if result.returncode == 0:
return True
elif result.returncode == 1:
return False
else:
logger.warn(
"Unexpected return code from tmux has-session: %d",
result.returncode
)
logger.warn("STDOUT from tmux has-session: %s", result.stdout)
logger.warn("STDERR from tmux has-session: %s", result.stderr)
raise RuntimeError(
"Unexpected return code %d from tmux has-session" %
result.returncode
)
class PaneGrid(SeperateWindows):
def __init__(self, files_to_launch, tmux_session_name, launcher_cmd=None):
super().__init__(files_to_launch, tmux_session_name, launcher_cmd)
def _setup_tmux(self):
"""
Setup tmux for the launch.
"""
# Create the grid - we started with 1 window, so create 1 less
# than we need.
for i in range(len(self._files_to_launch)-1):
subprocess.run(
[
"tmux", "split-window", "-t",
"%s:" % self._tmux_session_name, "-d",
],
check=True,
)
# Retile so there's space to create the next one
subprocess.run(
[
"tmux", "select-layout", "-t",
"%s:" % self._tmux_session_name, "tiled",
],
check=True,
)
logger.debug("Created panes")
def _select_next_window(self):
"""
Select the next window ready to launch the next script.
"""
logger.debug("Selecting next pane.")
subprocess.run(
[
"tmux", "select-pane", "-t",
"%s:.+" % self._tmux_session_name,
],
check=True,
)
def get_files(path, pattern='*'):
"""
Finds all files matching the glob pattern in the directory
given.
Args:
path: string or path-like object which is the directory
to search.
pattern: glob pattern to match (defaults to '*')
Returns: list of path-like objects for each file found
"""
path = Path(path) # Make it path-like
return list(path.glob(pattern)) # Well, that was easy...
if __name__ == '__main__':
logging.basicConfig()
# Do stuff
parser = argparse.ArgumentParser(
description="Re-run scripts with tmux, if not already"
" running"
)
parser.add_argument(
'-d', '--debug',
action='store_true',
help="Turn on debugging output",
)
parser.add_argument(
'-p', '--path',
default=Path('~/python/scripts').expanduser(),
help="Path to scripts to launch",
)
parser.add_argument(
'--pattern',
default="*.py",
help="Glob pattern for files to be launched.",
)
parser.add_argument(
'--launch',
default="/usr/bin/env python",
help="Command used to launch the scripts - script name"
" will be passed as an extra argument at the end.",
)
parser.add_argument(
'-m', '--mode',
default="grid",
choices=["grid", "separate"],
help="Mode used to launch scripts in tmux - 'grid' will"
" create a grid of panes large enough for all of"
" them in a single windows, 'separate' will launch"
" each in its own tmux window",
)
parser.add_argument(
'-s', '--session',
default="my-scripts",
help="Name of the tmux session to check for/start",
)
args = parser.parse_args()
if args.debug:
# Set the level on the root logger
logging.getLogger().setLevel(logging.DEBUG)
logger.debug("Path is %s", args.path)
logger.debug("Pattern is %s", args.pattern)
logger.debug("Launcher is %s", args.launch)
logger.debug("Mode is %s", args.mode)
logger.debug("Tmux name is %s", args.session)
# Note this will not work with escaped or quoted spaces - more
# advanced parsing will be required for that.
if ' ' in args.launch:
launcher = args.launch.split(' ')
else:
launcher = [args.launch]
files = get_files(args.path, args.pattern)
logger.debug("There are %d files to launch.", len(files))
if args.mode == 'separate':
launcher_class = SeperateWindows
elif args.mode == 'grid':
launcher_class = PaneGrid
else:
logger.error('Unrecognised mode: %s', args.mode)
raise RuntimeError("Unknown mode %s" % args.mode)
# Construct the launcher (of whichever type)
launcher = launcher_class(files, args.session, args.launch)
if not launcher.tmux_session_exists():
logger.info(
"Restarting tmux and scripts,"
" session with name %s not found",
args.session
)
launcher.launch()
else:
logger.debug("tmux with name %s found, doing nothing.",
args.session)