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)