'''Daemux lets you run daemons in a tmux pane.
That way, you wan write programs that launch long-running background
tasks, and check these tasks' health by hand, relaunch them, etc. by
attaching to the corresponding pane in tmux.
>>> import daemux
>>> # session, window, and pane are implicitely deduced if
>>> # not explicitely specified
>>> yes = daemux.start('yes')
>>> yes.status()
'running'
>>> # One can reattach from somewhere else
>>> yes2 = daemux.reattach(session='yes', window='yes', pane=-1)
>>> yes2.status()
'running'
>>> # Reattaching gives full control
>>> yes2.stop()
>>> yes2.status()
'ready'
>>> # Control is still available from the original instance
>>> yes.status()
'ready'
>>> yes.start()
>>> yes2.status()
'running'
>>> yes.stop()
'''
import libtmux
import subprocess
import time
__version__ = '0.0.13'
[docs]class Daemon:
"""Handle tmux session, window and pane to control the daemon."""
[docs] def __init__(self, cmd, session=None, window=None, pane=None):
'''Create or attach to a session/window/pane for command cmd.
Args:
cmd: The command to run to start the daemon.
session: The name of the tmux session in which to
run the daemon. Derived from `cmd` if None.
Will be created if it does not already exists.
window: The name of the tmux window (inside of `session`)
in which to run the daemon. Derived frm `cmd` if None.
Will be created if it does not already exists.
pane: The number of the pane (inside of `window`) in which
to run the daemon. A new pane will be created if None.
As many panes as necessary will be created so that
pane number `pane` exists. Python indexes work, so
asking for pane e.g. -1 makes sense.
'''
self.cmd = cmd
if window is not None and session is None:
raise ValueError("If window is set, session should be set.")
if pane is not None and (window is None or session is None):
raise ValueError('If pane is set, '
'window and session should be set.')
if session is None:
session = cmd.split()[0]
if window is None:
window = cmd.split()[0]
self.server = libtmux.Server()
self.session = self.server.find_where({'session_name': session})
if not self.session:
self.session = self.server.new_session(session)
# Rename the implicitely created window so that it can be found
# on next line
self.session.list_windows()[0].rename_window(window)
self.window = self.session.find_where({'window_name': window})
if not self.window:
self.window = self.session.new_window(window)
if pane is not None and pane != 0:
raise ValueError('pane was specified as {}, but window {}'
' did not exist (it does now). Legal values'
'of pane were therefore only 0 '
'and None.'.format(pane, window))
if pane is None:
pane = 0 # So that we wont split the window we just created
if pane is None: # Creation of a new pane
self.pane = self.window.split_window()
else:
while max(-pane - 1, pane) >= len(self.window.list_panes()):
# Create as many panes as necessary to honor request
self.window.split_window()
self.pane = self.window.list_panes()[pane]
if cmd is not None:
self.pane.send_keys("# Pane {},"
" ready to run daemon {}".format(self.pane,
self.cmd))
[docs] def pane_ps(self):
'''Return the ps output for processes running in our pane.'''
return subprocess.check_output('ps -t {}'
.format(self.pane['pane_tty']),
shell=True).decode('utf8')
[docs] def pane_output(self):
'''Return the contents of the pane.'''
# FIXME: -32000 should be chaged when tmux v2 becomes widly
# available to just '-', meaning 'all history'.
return '\n'.join(self.pane.cmd('capture-pane', '-p',
'-S', '-32000').stdout)
[docs] def status(self):
'''Return the putative status of the daemon.
Return:
'running' if more than one process appear to be running in
the daemon's pane's tty
'ready' if only one process is running in the daemon's pane's tty
'''
# There is a header line
nb_processes = len(self.pane_ps().strip().split('\n')) - 1
if nb_processes > 1:
return 'running'
assert nb_processes == 1, '''ps output is not as expected:
{}'''.format(self.pane_ps())
return 'ready'
[docs] def restart(self, timeout=10):
"""Relaunch the daemon by sending an arrow up and enter."""
self.stop()
self.pane.cmd('send-keys', 'Up')
self.pane.enter()
self.wait_for_state('running', timeout)
[docs] def start(self, timeout=10):
"""Start the daemon."""
if self.status() == 'running':
raise RuntimeError('The shell is not ready to launch our daemon.\n'
'Existing processes:\n'
'{}'.format(self.pane_ps()))
if self.cmd is None:
return self.restart()
self.pane.send_keys(self.cmd)
self.wait_for_state('running', timeout)
[docs] def wait_for_state(self, state, timeout=10, action=None):
'''Wait for timeout or for status to change to state before returning.
If action is specified, it is called every second while status is not
at state.
'''
start = time.time()
while self.status() != state:
if action is not None:
action()
time.sleep(1)
if time.time() - start > timeout:
raise RuntimeError("Could not get the daemon to switch to "
"state {}."
" Current output is:\n{}"
.format(state, self.pane_output()))
[docs] def stop(self):
'''Send Ctrl-Cs to the pane the daemon is running on until it stops.'''
self.pane.cmd('send-keys', 'C-c')
self.wait_for_state('ready', action=lambda: self.pane.cmd('send-keys', 'C-c'))
[docs]def start(cmd, **kwargs):
'''Start a new daemon and return it.
The daemon is created with the arguments given to start.
See :py:func:`Daemon.__init__` for details.
One can give an explicit tmux session/window/pane hierarchy:
>>> import daemux
>>> d = daemux.start(cmd='yes', session='yes', window='yes', pane=-1)
>>> d.stop()
'''
answer = Daemon(cmd, **kwargs)
answer.start()
return answer
[docs]def reattach(session, window, pane):
'''Return the Daemon Object tied to the specified tmux hierarchy.'''
return Daemon(cmd=None, session=session, window=window, pane=pane)
if __name__ == '__main__':
import doctest
doctest.testmod()