#!/usr/bin/env python import sys, copy import string from afnipy import afni_base as ab # This is a small library of inelegant functions for writing out a # command in nicely vertically-spaced fashion over multiple lines. # The functions do some guesswork about how to push things to multiple # lines. # # started by PA Taylor (SSCC, NIMH, NIH) # # ------------------------------------------------------------------------- # 2022-01-14, ver 1.0 : creazione # 2022-01-14, ver 1.1 : have afni_niceify_cmd_str() pass along kwargs # 2022-03-20, ver 1.2 : allow user to input list of args for splitting # (bc opt names can take sub-opts that we would not # want to split at---e.g., within afni_proc.py calls) # 2022-03-20, ver 1.3 : introduce second-level splitting of arg lists, to # account for a primary option taking several # secondary options (space the latter out to sep # rows now---will be useful for AP) # 2022-03-21, ver 1.4 : recognize (and ignore) escaped quotes, when # calculating where quoted blocks occur # 2022-08-09, ver 1.5 : tweak opt proc: recognize -1Dmatrix_apply as # an opt (even though it starts with -1. # Also, remove trailing whitespace in final line # of cmd. # 2022-08-19, ver 1.6 : isnumeric() -> isdigit(), for Py2.7 compatibility. # 2022-10-07, ver 1.7 : afni_niceify_cmd_str() gets new big_list kwarg # to play nice with AP help examples # 2023-22-22, ver 1.8 : kwarg to auto-guess prog opts, if possible # 2023-22-22, ver 1.9 : add '-overwrite' for all AFNI programs # ------------------------------------------------------------------------- VER_MAJOR = sys.version_info.major # DEFAULTS for kwargs in these funcs AD = { \ 'quote_pair_list' : [ "'", '"' ], 'list_cmd_args' : [], 'maxcount' : 10**6, 'nindent' : 4, 'max_lw' : 78, 'max_harg1' : 40, 'comment_start' : None, 'verb' : 0, } all_good_opt_chars = string.ascii_letters all_good_opt_chars+= ''.join([str(x) for x in range(10)]) all_good_opt_chars+= ''.join(['-', '_']) # ------------------------------------------------------------------------- def guess_prog_opt_list(pname, verb=0): '''For a program named pname, try to guess the available option list automagically. There is a bit of a triage of methods for doing so, which might change over time. Parameters ---------- pname : str name of program (presumably an AFNI prog) Returns ------- lopt : list list of found options (= strings). If none were found, return empty list ''' do_cap = True lopt = [] # First way: certain AFNI Python progs have -show_valid_opts opt if pname.endswith('.py') and not(len(lopt)) : cmd = pname + ' -show_valid_opts' com = ab.shell_com(cmd, capture=do_cap) stat = com.run() if not(stat) : # extract options cmd = pname + ' -show_valid_opts' cmd += """ | awk '{print $3}'""" com = ab.shell_com(cmd, capture=do_cap) stat = com.run() kopt = copy.deepcopy(com.so) lopt = adjunct_opt_list_cleanup(kopt) # Second way: use apsearch, should have most/all AFNI progs if not(len(lopt)) : # all AFNI progs should have a guesstimated list here cmd = 'apsearch -list_popts ' + pname com = ab.shell_com(cmd, capture=do_cap) stat = com.run() if not(stat) : kopt = copy.deepcopy(com.so) lopt = adjunct_opt_list_cleanup(kopt) if len(lopt) and not('-overwrite' in lopt) : lopt.append('-overwrite') # Empty ending: found nothing if not(len(lopt)) and verb : print("+* WARN: no options found for prog:", pname) return lopt def adjunct_opt_list_cleanup(L, yes_dash_start=True, no_single_char=True, only_single_dash=True, no_only_dash=True, split_at_bad=True, ): '''Option lists can contain bad/extraneous characters, as well as non-options. This function tries to clean them up in various ways Parameters ---------- L : list input list of potential options, for cleaning yes_dash_start : bool if True, an opt *not* starting with '-' is removed from the list no_single_char : bool if True, an opt that has len=1 is removed from the list only_single_dash : bool if True, an opt starting with '--' is removed from the list no_only_dash : bool if True, an opt that is only made up of dashes is removed from the list split_at_bad : bool go through opt name; if a bad char appears, split and choose the [0]th part. In this way, bad chars are removed, as well as anything that follows (bc sometimes a colon plus other stuff is attached in apsearch output). Any char not in the all_good_opt_chars list is considered bad. Returns ------- M : list list of (hopefully cleaned) options; might be shorter than L ''' N = len(L) M = [] for ii in range(N): opt = L[ii].strip() # remove start/end whitespace # start checks if opt and yes_dash_start : if not(opt.startswith('-')) : opt = '' if opt and no_single_char : if len(opt) < 2 : opt = '' if opt and only_single_dash : if opt.startswith('--') : opt = '' if opt and no_only_dash : if not(len(opt.replace('-', ''))) : opt = '' if opt and split_at_bad : for c in opt: if not(c in all_good_opt_chars) : opt = opt.split(c)[0] # end of checks if opt : M.append(opt) return M # Primarily a suppl function of split_str_outside_quotes() def find_next_quote_in_str( sss, quote_pair_list=AD['quote_pair_list'] ): '''Find the first instance of ' or " appearing in the string, by default. The user can optionally specify a different list of strings to search for (e.g., just one particular quote); the quote_pair_list must always be a list. If one is found, return: + the index i where it is found; + the left index L of any non-whitespace block it is part of; + the right index plus one R of any non-whitespace block it is part of (so, that str[L:R] would select the intact island, in Pythonese); + which kind of quote it is. Otherwise, return: -1, -1, -1, None. This function will ignore escaped quotes. ''' if type(quote_pair_list) != list : print("** ERROR: need to input a *list* of strings for searching") return -1, None # initialize a few things N = len(sss) qind = 0 qtype = None FOUND = 0 # find the first index where one of the quote_pair_list items appears; # don't use str.find() bc we want to avoid escaped quotes # NB: at the moment, each quote pair list item is assumed to be a single # char. Could generalize, but don't see the need at commandline at # present; maybe the for the '[[' in bash scripts someday? while qind < N : if sss[qind] in quote_pair_list: # ensure quote is not escaped (which cannot happen on [0]th char) if qind == 0: FOUND = 1 elif sss[qind-1] != '\\' : FOUND = 1 if FOUND : qtype = sss[qind] break qind+= 1 # now, qind contains the leftmost quote found (if any), and qtype # records what type it is, from within quote_pair_list items if FOUND : # calc left (=closed) boundary of non-whitespace island lind = qind while lind >= 0 : if not(sss[lind].isspace()) : lind-= 1 else: break lind+= 1 # calc right (=open) boundary of non-whitespace island rind = qind while rind < N : if not(sss[rind].isspace()) : rind+= 1 else: break return qind, lind, rind, qtype else: return -1, -1, -1, qtype # ------------------------------------------------------------------------- # Primarily a supply func of listify_argv_str() def split_str_outside_quotes( sss, quote_pair_list=AD['quote_pair_list'], maxcount=AD['maxcount'] ): '''For the input string sss, leave parts that are contained within quotes (either ' or ") intact, and split the rest at whitespace. Retain any non-whitespace chars that a quote is attached to as part of the intact region. If strings are nested, the outer pair takes precedence (so, nesting doesn't really matter). maxcount is a number to guard against infinite loops. Should just be "big". Return a list of strings. ''' if type(sss) != str : print("** ERROR: need to input a single string for searching") return [] elif len(sss) == 0: return [] olist = [] count = 0 newstart = 0 # track each new start of string search ttt = sss[:] top, ltop, rtop, qtype = find_next_quote_in_str(ttt, quote_pair_list=AD['quote_pair_list']) while top >= 0 : if top : list1 = ttt[:ltop].split() olist.extend(list1) newstart+= top+1 # look for a partner/closing quote of the matching variety top2, ltop2, rtop2, qtype2 = find_next_quote_in_str(ttt[top+1:], [qtype]) if top2 >= 0 : # The case of finding a partner quote. NB: because of the # way we check substrings, indices look funny here, but work list2 = [ttt[ltop:top+1+rtop2]] olist.extend(list2) ttt = ttt[top+1+rtop2:] newstart+= rtop2 top, ltop, rtop, qtype = find_next_quote_in_str(ttt, quote_pair_list=AD['quote_pair_list']) else: # The case of not finding a partner quote. At present, we # then ignore any other kinds of quotes. Have to ponder # if that is reasonable... print("+* WARN: char [{}] is unmatched quote {}, ignore it" "".format(newstart+top2, qtype)) list2 = ttt[ltop:].split() olist.extend(list2) ttt = '' top = -1 # purely to guard against infinite looping; shouldn't happen count+=1 if count > maxcount: print("** ERROR: infinite loop (?) encountered. Truncating.") return [] # split any remainder if len(ttt): olist.extend(ttt.split()) return olist # ------------------------------------------------------------------------- # A primary-ish function to organize a list-of-sublists form for the cmd. def listify_argv_str( sss, quote_pair_list=AD['quote_pair_list'], list_cmd_args=AD['list_cmd_args'], maxcount=AD['maxcount'] ): '''Take a command line string sss, and turn it into a list of lists, where each 'inner'/sublist would be one line of items when printed later. For example, an option flag (no args) would be a single-item sublist; an option with one or more args would comprise a single sublist; and a 'position' argument would be a single-item sublist. If no list_cmd_args is provided, then there is some fanciness about recognizing that '-5' is likely an argument for an option, rather than an option itself... Return the list of sublists (= the big_list). Each sublist will (potentially) be one row in the output (though line spacing is handled elsewhere). Each element in each sublist is stripped of whitespace here. ''' if type(sss) != str : print("** ERROR: need to input a single string") return [] # do whitespace-based splitting while also retaining quoted regions arg_list = split_str_outside_quotes(sss, quote_pair_list=AD['quote_pair_list'], maxcount=AD['maxcount']) N = len(arg_list) if not(N) : print("+* WARN: nothing in command line string?") return [] if list_cmd_args : return make_big_list_from_args(arg_list, list_cmd_args) else: return make_big_list_auto(arg_list) def make_big_list_auto(arg_list): '''Take the arg list already passed through whitespace splitting and quote-pairing and make a 'big_list' of the opts. Namely, return a list of sub-lists, where the [0]th list is the program name and each subsequent list will contain an option and any args for it. In this function, the determination of a new sub-list is made automatically with some logic. See make_big_list_from_args(...) if you know the opt names for the program and want to split arg_list into sublists using that. ''' # initialize list: [0]th item should be program name big_list = [[arg_list[0]]] mini_list = [] i = 1 N = len(arg_list) while i < N : iarg = arg_list[i] narg = len(iarg) if iarg[0] == '-' and narg == 1 : # looks like new opt: store any existing (non-empty) # mini_list, and start new one with this str if mini_list : big_list.append(mini_list) mini_list = [iarg] elif iarg[0:2] == '--' or \ ( iarg[0] == '-' and not(iarg[1:].isdigit()) ) : # looks like new opt: store any existing (non-empty) # mini_list, and start new one with this str if mini_list : big_list.append(mini_list) mini_list = [iarg] elif mini_list : # otherwise, if a mini_list exists, keep adding to it mini_list.append(iarg) else: # otherwise, there is no mini_list and this looks like an # arg-by-position: is its own mini_list big_list.append([iarg]) i+= 1 if mini_list : # add any remaining mini_list big_list.append(mini_list) return big_list def make_big_list_from_args(arg_list, list_cmd_args=AD['list_cmd_args']): '''Take the arge list already passed through whitespace splitting and quote-pairing and make a 'big_list' of the opts. Namely, return a list of sub-lists, where the [0]th list is the program name and each subsequent list will contain an option and any args for it. In this function, the determination of a new sub-list is made using a provided list of args. See make_big_list_auto(...) if you don't know the opt names for the program and want to split arg_list into sublists using some reasonable/automated logic. ''' # initialize list: [0]th item should be program name big_list = [[arg_list[0]]] mini_list = [] i = 1 N = len(arg_list) while i < N : iarg = arg_list[i] narg = len(iarg) if iarg in list_cmd_args : # looks like new opt: store any existing (non-empty) # mini_list, and start new one with this str if mini_list : big_list.append(mini_list) mini_list = [iarg] elif mini_list : # otherwise, if a mini_list exists, keep adding to it mini_list.append(iarg) else: # otherwise, there is no mini_list and this looks like an # arg-by-position: is its own mini_list big_list.append([iarg]) i+= 1 if mini_list : # add any remaining mini_list big_list.append(mini_list) return big_list # ------------------------------------------------------------------------- # A primary function for converting a big_list (= list-of-sublist) # form of cmd to a multiline-string def pad_argv_list_elements( big_list, nindent=AD['nindent'], max_lw=AD['max_lw'], max_harg1=AD['max_harg1'], comment_start=AD['comment_start'] ): '''The list big_list is a list of lists, each of the 'inner' lists containing string elements that should be pieced together into a single row. This function makes a first iteration through at padding pieces, and then a second at building the full command string. Formatting is as follows: + Each line should be at most max_lw chars, except where unavoidable: - if it is one giant piece causing it, leave it where it is, unless the piece comes after a couple other items, in which case kick it to the next line - if it is a small piece sticking over, kick the piece to the next line + Each line after the initial one will be indented by nindent spaces. + The [1]th element in each line should be vertically aligned at horizontal spacing value harg1 - unless the [0]th arg is too long, in which case it will sit further right - harg1 is set by being the min of the max_harg1 parameter and the max char length of the end of the [0]th elements. + The comment_start can be applied to the start of each line: for example, for Python one might put comment_start='# ', then each line would be commented (and space-padded by 1). Returns the full command string, with continuation-of-line (COL) characters, uniform vertical spacing, etc. ''' if type(big_list) != list : print("** ERROR: need to input a list of lists") return '' if comment_start : if type(comment_start) != str : print("** ERROR: comment_start parameter needs to be a str") return '' # include this str in line width spacing considerations. ncomm = len(comment_start) max_lw-= ncomm nbig = len(big_list) indent = nindent * ' ' max_lw-= 1 # bc of COL char # ------------ pad each row/sublist item appropriately ------------ # + indent [0] items # + figure out the appropriate vertical spacing for any [1]st args # + apply padding to [0]th items for [1]st arg vert spacing # + we pad here, so we know how much horizontal spacing each element # takes max_harg0 = -1 for i in range(1, nbig): # [0]th element gets indented and double padded (easier to read) big_list[i][0] = indent + big_list[i][0] + ' ' # work to calc further reach of [0]th elements if len(big_list[i][0]) > max_harg0 : max_harg0 = len(big_list[i][0]) # add a space to each remaining arg ilen = len(big_list[i]) for j in range(1, ilen): big_list[i][j] = big_list[i][j] + ' ' # the vertical spacing for all possible [1]st elements harg1 = min(max_harg1, max_harg0) # when kicking an element to the next line, how to pad it harg1_ind = max(nindent, harg1) * ' ' # pad each row's [0]th arg for vert spacing of any [1]st args for j in range(1, nbig): big_list[j][0] = '''{text:<{harg1}s}'''.format(text=big_list[j][0], harg1=harg1) # ----------------------- make output str -------------------------- # Start with the [0][0]th element, which should be the prog name. # The cheese stands alone in this row ostr = '''{text:<{lw}s}'''.format(text=big_list[0][0], lw=max_lw) # loop over each sub_list for j in range(1, nbig): jlist = big_list[j] ostr += '''\\\n''' # start this row's/line's string, and keep running length tally row = '''{text}'''.format(text=jlist[0]) nrow = len(row) # loop over the items in the sub_list (skipping the [0th]) for k in range(1, len(jlist)): if nrow + len(jlist[k]) > max_lw and \ (len(harg1_ind + jlist[k]) < max_lw or k>1) : # Looks like we should put this item into a new # row. So, finish the current row... ostr += '''{text:<{lw}s}'''.format( text=row, lw=max_lw ) ostr += '''\\\n''' # ... and then start building the next one row = '''{pad}{text: 2 and len(jlist[k]) > 1 and \ jlist[k][0] == '-' and \ not(jlist[k][1].isdigit() or jlist[k][1] == '.') : # looks like the sub_list itself contains a list of # opts, so finish the current row..., just like above ostr += '''{text:<{lw}s}'''.format( text=row, lw=max_lw ) ostr += '''\\\n''' # ... and then start building the next one row = '''{pad}{text: