#!/bin/python3
import pandoc
import pandoc . types as t
import sys
from dataclasses import dataclass
from typing import *
# There are three parts:
# - Parse a given markdown and extract the commands
# - Also: keep track of the groups / ...
# - Let the user decide, what to run / edit / split / skip
# - Run the commands remotely
# Usage: $0 remote.host.name md1 ...
host : str
directory : str = None
def main ( ) :
if len ( sys . argv ) < 3 :
print ( f " Usage: { sys . argv [ 0 ] } hostname file1.md ... " )
exit ( 1 )
# Here I would use Haskell's pointfree functions, so that host is implicitly passed...
# Never mind, it will be a global variable NOTE THIS!!!
global host
host = sys . argv [ 1 ]
for file in sys . argv [ 2 : ] :
process_file ( file )
@dataclass
class Command :
user : str
directory : Union [ str , None ]
command : str
context : str
def stringify ( block ) - > str :
"""
Take an object of some of pandoc . types and make the best of it .
"""
# The pandoc library has a fancy function for that...
return pandoc . write ( block )
def extract_snippets ( pd : t . Pandoc ) - > Sequence [ Tuple [ str , str ] ] :
"""
This extracts snippets from the code . The problem is , that we want context with it , so we return a list of tuples ( previous_block_stringified , code_snippet ) .
"""
result = [ ]
last_block = " "
for block in pd [ 1 ] :
if isinstance ( block , t . CodeBlock ) :
result . append ( ( last_block , block [ 1 ] ) )
last_block = stringify ( block )
return result
def parse_snippet ( s : Tuple [ str , str ] ) - > Sequence [ Command ] :
# Will return always a list, even if it has only one element.
# This is where we handle blank lines, comments, cd's and sudo's, so that the Commands in the output are "runnable"
# The original context should be mentioned for all hunks
context = s [ 0 ]
# We now check the separate lines and possibly tweak them (e.g. by removing sudo-s)
lines = s [ 1 ] . split ( ' \n ' )
global directory
result = [ ]
for line in lines :
user = None # This allows (in conjunction with the right order of arguments to 'ssh') to specify the default user in the hostname (as 'user@host.name')
line = line . strip ( )
if line . startswith ( ' cd ' ) :
parts = line . split ( )
assert len ( parts ) == 2
directory = parts [ 1 ]
continue
elif line . startswith ( ' sudo -u ' ) :
parts = line . split ( maxsplit = 4 )
assert parts [ 3 ] == ' -H ' , " Insecure sudo? "
user = parts [ 2 ]
cmd = parts [ 4 ]
elif line . startswith ( ' sudo ' ) :
parts = line . split ( maxsplit = 1 )
assert not parts [ 1 ] . startswith ( ' - ' ) , " Weird sudo "
user = ' root '
cmd = parts [ 1 ]
elif line == ' ' :
# Only add it to context for reference
context = f ' { context } \n '
continue
else :
# No sudo or cd, just a command.
cmd = line
result . append ( Command ( user = user , directory = directory , command = cmd , context = context ) )
# Let's have the previously processed commands in the context
context = f " { context } \n CMD: { user } : { directory if directory else ' ?? ' } $ { cmd } "
return result # I miss the right feature of Nim lang...
def user_action ( hunk : Sequence [ Command ] , context = None ) - > Sequence [ Command ] :
# Maybe modify the commands to run.
# This should have a simple UI: show the hunk with context (ideally in grey), ask what to do, return the wanted command
# Actions: yes(y): accept input; no(n): skip this command; edit(e): launch $EDITOR or vim on the hunk; quit(q): terminate; split(s): split hunks to individual lines and ask for each one.
# FIXME: We don't implement splitting yet, because it's simple to comment / delete in the editor
# FIXME: We only show given context and don't care for the command's one; that's wrong.
def stringify_hunk ( hunk ) :
result = ' '
for cmd in hunk :
global host
result + = f ' SSH: { cmd . user } @ { host } { cmd . directory } $ { cmd . command } \n '
return result
# TODO: Print context in color (gray?)
print ( ' ------------------------------------------- ' )
print ( context )
print ( )
print ( stringify_hunk ( hunk ) )
# TODO: Print prompt in color (green?)
answer = input ( " What to do: edit(e), run(y, default), skip(n)? " )
if answer is None or answer == ' ' :
# Default is run, for convenience
answer = ' Y '
if answer . startswith ( ( ' e ' , ' E ' ) ) :
import tempfile
with tempfile . NamedTemporaryFile ( mode = ' r+ ' ) as tmpfile :
# Fill the file with data
for cmd in hunk :
tmpfile . file . write ( f " { cmd . user if cmd . user else ' ' } \t { cmd . directory if cmd . directory else ' ' } \t { cmd . command } \n " )
tmpfile . file . flush ( ) # Should not be needed, but better safe than sorry
# Let the user edit the file
import os
editor = os . getenv ( ' EDITOR ' )
if editor is None :
# Fallback to vim
editor = ' vim '
import subprocess
subprocess . run ( [ editor , tmpfile . name ] )
# Read the file back
tmpfile . file . seek ( 0 )
new_hunk = [ ]
for line in tmpfile . file . readlines ( ) :
if line == ' ' :
continue
parts = line . split ( ' \t ' , maxsplit = 2 )
# Change user to None if it is not specified
parts [ 0 ] = parts [ 0 ] . strip ( ) if parts [ 0 ] . strip ( ) != ' ' else None
# Change directory to None if it is not specified
parts [ 1 ] = parts [ 1 ] . strip ( ) if parts [ 1 ] . strip ( ) != ' ' else None
new_hunk . append ( Command ( user = parts [ 0 ] , directory = parts [ 1 ] , command = parts [ 2 ] , context = None ) )
# Python has no goto for restarting, but we can recurse
return user_action ( new_hunk , context )
elif answer . startswith ( ( ' y ' , ' Y ' ) ) :
return hunk
elif answer . startswith ( ( ' n ' , ' N ' ) ) :
return [ ]
else :
# Bad choice or error. Notify user and try again
print ( " Bad choice, or maybe bug. " )
# No goto, use recursion
return user_action ( hunk , context )
def run_command ( cmd : Command ) :
# Generate and run the SSH command.
global host
local_command = [ ' ssh ' ]
remote_command = cmd . command
if cmd . user is not None :
local_command . extend ( [ ' -l ' , cmd . user ] )
local_command . append ( host ) # SSH seems to honor the first specified parameter. This allows to have username as a part of the hostname, yet still be able to honor the sudo-s in the sinppets.
if cmd . directory is not None :
remote_command = f ' cd { cmd . directory } ; { remote_command } '
local_command . append ( remote_command )
# Run
import subprocess
process = subprocess . run ( local_command )
if process . returncode != 0 :
# TODO: Print in color (red?)
answer = input ( f " Command { process . args } failed with return code { process . returncode } . Continue? " )
if not answer . startswith ( ( ' Y ' , ' y ' , ' a ' , ' A ' ) ) : # We are Czech, so "ano" is a valid answer :-)
print ( " Bailing out " )
sys . exit ( 1 )
def process_file ( fn : str ) :
"""
To be called upon each file on the command line . This function should perform all the steps needed in order to remotely run a parsed markdown , id est the three parts above .
"""
pd = pandoc . read ( file = fn )
snippets = extract_snippets ( pd )
for snip in snippets :
cmds = parse_snippet ( snip )
cmds = user_action ( cmds , context = cmds [ 0 ] . context )
for cmd in cmds :
run_command ( cmd )
if __name__ == ' __main__ ' :
main ( )