diff --git a/README.rst b/README.rst index 9364b65..1222d39 100644 --- a/README.rst +++ b/README.rst @@ -49,6 +49,26 @@ Debian/Ubuntu users can get pip3 using: sudo apt-get install python3-pip +This will install Dave Hylands' official version. + +To replace it with this fork, create a directory on your PC and clone this repo to it. + +:: + + $ git clone https://github.com/peterhinch/rshell + +You need to replace a file in the rshell directory with one from this repo, namely rshell/main.py. +The original's location depends on your Python 3 version. To find it issue + +:: + + $ find /usr -type d -name "rshell" + /usr/local/lib/python3.6/dist-packages/rshell + +After replacing the file test the result by running rshell and issuing the lm command. Once satisfied +that this version is running, the clone directory tree may be deleted. I also recommend making this +README accessible via a weblink or otherwise. + Serial Port Permissions (linux) =============================== @@ -134,6 +154,8 @@ following displayed: Set the editor to use (default 'vi') -f FILENAME, --file FILENAME Specifies a file of commands to process. + -m MACRO_MODULE, --macros MACRO_MODULE + Specify a macro module. -d, --debug Enable debug features -n, --nocolor Turn off colorized output --wait How long to wait for serial port @@ -180,6 +202,12 @@ be used. Specifies a file of rshell commands to process. This allows you to create a script which executes any valid rshell commands. +-m MACRO_MODULE, --macros MACRO_MODULE +-------------------------------------- + +Specifies a Python module containing macros which may be expanded at +the rshell prompt. See below for the file format and its usage. + -n, --nocolor ------------- @@ -393,7 +421,7 @@ df -h, --human-readable Prints sizes in a human-readable format using power of 1024 -H, --si Prints sizes in a human-readable format using power of 1000 -Gets filesystem available space based on statvfs. Granularity is limited +Gets filesystem available space based on statvfs. Granularity is limited to filesystem block size. @@ -571,6 +599,12 @@ Synchronisation is performed by comparing the date and time of source and destination files. Files are copied if the source is newer than the destination. +Synchronisation can be configured to ignore files such as documents, +usually to conserve space on the destination. This is done by means +of a file named .rshell-ignore. This should comprise a list of files +and/or subdirectories with each item on a separate line. If such a +file is found in a source directory, items found in the file's +directory that match its contents will not be synchronised. shell ----- @@ -590,6 +624,105 @@ This will invoke a command, and return back to rshell. Example: will flash the pyboard. +lm +-- + +:: + + usage lm [macro_name] + + If issued without an arg lists available macros, otheriwse lists the + specified macro. + +m +- + +:: + + usage m macro_name [arg0 [arg1 [args...]]] + + Expands the named macro, passing it any supplied positional args, + and executes it. + +Macros +====== + +Macros enable short strings to be expanded into longer ones and enable +common names to be used to similar or different effect across multiple +projects. They also enable rshell functionality to be enhanced, e.g. +by adding an mv command to move files. + +Macros are defined by macro modules: these comprise Python code. Their +filenames must conform to Python rules and they should be located on the +Python path. + +If a module named rshell_macros.py is found, this will be imported. An example +rshell_macros.py is shown below. + +If rshell is invoked with -m MACRO_MODULE argument, the specified Python +module will (if found) be imported and its macros appended to any in +rshell_macros.py. + +Macro modules should contain a dict named macros. Each key should be a string +specifying the name; the value may be a string (being the expansion) or a +2-tuple. In the case of a tuple, element[0] is the expansion with +element[1] being an arbitrary help string. + +The macro name must conform to Python rules for dict keys. The expansion +string may not contain newline characters. Multi-line expansions are +supported by virtue of rshell's ; operator: see the mv macro below. + +The expansion string may contain argument specifiers compatible with the +Python string format operator. This enables arguments passed to the macro +to be expanded in ways which are highly flexible. + +Because macro modules contain Python code there are a variety of ways to +configure them: for example macro modules can impport other macro modules. +One approach is to use rshell_macros.py to define global macros applicable +to all projects with project-specific macros being appended with the -m +command line argument. + +rshell_macros.py: + +:: + + macros = {} + macros['..'] = 'cd ..' + macros['...'] = 'cd ../..' + macros['ll'] = 'ls -al {}', 'List a directory (default current one)' + macros['lf'] = 'ls -al /flash/{}', 'List contents of target flash' + macros['lsd'] = 'ls -al /sd/{}' + macros['lpb'] = 'ls -al /pyboard/{}' + macros['mv'] = 'cp {0} {1}; rm {0}', 'File move command' + +A module specific to the foo project: + +:: + + macros['sync'] = 'rsync foo/ /flash/foo/', 'Sync foo project' + macros['run'] = 'repl ~ import foo.demos.{}', 'Run foo demo e.g. > m run hst' + macros['proj'] = 'ls -l /flash/foo/{}', 'List directory in foo project.' + macros['cpf'] = 'cp foo/py/{} /flash/foo/py/; repl ~ import foo.demos.{}', 'Copy a py file, run a demo' + macros['cpd'] = 'cp foo/demos/{0}.py /flash/foo/demos/; repl ~ import foo.demos.{0}', 'Copy a demo file and run it' + +If at the rshell prompt we issue + +:: + + > m cpd hst + +this will expand to + +:: + + > cp foo/demos/hst.py /flash/foo/demos/; repl ~ import foo.demos.hst + +In general args should be regarded as mandatory. Any excess args supplied +will be ignored. In the case where no args are passed to a macro that +expects some, the macro will be expanded and run with each placeholder +replaced with an empty string. This enables directory listing macros such as +'proj' above to run with zero or one argument. + Pattern Matching ================ diff --git a/rshell/main.py b/rshell/main.py index d0075b6..55136da 100755 --- a/rshell/main.py +++ b/rshell/main.py @@ -19,6 +19,7 @@ # tree and not from some installed version. import sys +sys.path.append(".") # so that rshell_macros can be found in the cwd try: import rshell.dfutils as dfutils from rshell.getch import getch @@ -55,9 +56,13 @@ import shlex import itertools from serial.tools import list_ports +import importlib import traceback +# Macros: values are strings or 2-lists +macros = {} + if sys.platform == 'win32': EXIT_STR = 'Use the exit command to exit rshell.' else: @@ -153,6 +158,9 @@ RTS = '' DTR = '' +IGFILE_NAME = '.rshell-ignore' +MACFILE_NAME = 'rshell_macros' + # It turns out that just because pyudev is installed doesn't mean that # it can actually be used. So we only bother to try if we're running # under linux. @@ -977,6 +985,21 @@ def rsync(src_dir, dst_dir, mirror, dry_run, print_func, recursed, sync_hidden): for name, stat in src_files: d_src[name] = stat + # Check source for an ignore file + all_src = auto(listdir_stat, src_dir, show_hidden=True) + igfiles = [x for x in all_src if x[0] == IGFILE_NAME] + set_ignore = set() + if len(igfiles): + igfile, mode = igfiles[0] + if mode_isfile(stat_mode(mode)): + with open(src_dir + '/' + igfile, 'r') as f: + for line in f.readlines(): + line = line.strip() + if line: + set_ignore.add(line) + else: + print_err('Ignore file "{:s}" is not a file'.format(IGFILE_NAME)) + d_dst = {} dst_files = auto(listdir_stat, dst_dir, show_hidden=sync_hidden) if dst_files is None: # Directory does not exist @@ -987,7 +1010,7 @@ def rsync(src_dir, dst_dir, mirror, dry_run, print_func, recursed, sync_hidden): d_dst[name] = stat set_dst = set(d_dst.keys()) - set_src = set(d_src.keys()) + set_src = set(d_src.keys()) - set_ignore to_add = set_src - set_dst # Files to copy to dest to_del = set_dst - set_src # To delete from dest to_upd = set_dst.intersection(set_src) # In both: may need updating @@ -1041,6 +1064,25 @@ def rsync(src_dir, dst_dir, mirror, dry_run, print_func, recursed, sync_hidden): if not dry_run: cp(src_filename, dst_filename) +def move_file(old_filename, new_filename): + """Creates one or more directories.""" + import os + try: + os.rename(old_filename, new_filename) + except: + return False + return True + +def mv(src_filename, dst_filename): + """Copies one file to another. The source file may be local or remote and + the destination file may be local or remote. + """ + src_dev, src_dev_filename = get_dev_and_path(src_filename) + dst_dev, dst_dev_filename = get_dev_and_path(dst_filename) + if src_dev is dst_dev: + # src and dst are either on the same remote, or both are on the host + return auto(move_file, src_filename, dst_dev_filename) + # rtc_time[0] - year 4 digit # rtc_time[1] - month 1..12 @@ -2216,6 +2258,78 @@ def do_boards(self, _): else: print('No boards connected') + def complete_m(self, text, line, begidx, endidx): # Assume macro works on filenames for completion. + return self.filename_complete(text, line, begidx, endidx) + + def do_m(self, line): + """m macro_name [[arg0] arg1]... + + Expand a macro with args and run. + """ + msg = '''usage m MACRO [[[arg0] arg1] ...] + Run macro MACRO with any required arguments. + In general args should be regarded as mandatory. In the case where + no args are passed to a macro expecting some the macro will be run + with each placeholder replaced with an empty string.''' + tokens = [x for x in line.split(' ') if x] + cmd = tokens[0] + if cmd in macros: + data = macros[cmd] + if isinstance(data, str): + go = data + else: # List or tuple: discard help + go = data[0] + if len(tokens) > 1: # Args to process + try: + to_run = go.format(*tokens[1:]) + except: + print_err('Macro {} is incompatible with args {}'.format(cmd, tokens[1:])) + return + else: + to_run = go.format('') + self.print(to_run) + self.onecmd(to_run) + elif cmd == '-h' or cmd == '--help': + self.print(msg) + else: + print_err('Unknown macro', cmd) + + def do_lm(self, line): + """lm + + Lists available macros. + """ + msg = '''usage lm [MACRO] + list loaded macros. + Positional argument + MACRO the name of a single macro to list.''' + if not macros: + print_err('No macros loaded.') + return + def add_col(l): + d = macros[l] + sp = '' + if isinstance(d, str): + cols.append((l, sp, d, '', '')) + else: + cols.append((l, sp, d[0], sp, d[1])) + + l = line.strip() + cols = [] + if l: + if l in macros: + add_col(l) + elif l == '-h' or l == '--help': + self.print(msg) + return + else: + print_err('Unknown macro {}'.format(l)) + return + else: + for l in macros: + add_col(l) + column_print('<<<<<', cols, self.print) + def complete_cat(self, text, line, begidx, endidx): return self.filename_complete(text, line, begidx, endidx) @@ -2937,6 +3051,9 @@ def do_shell(self, line): ), ) + def complete_rsync(self, text, line, begidx, endidx): + return self.filename_complete(text, line, begidx, endidx) + def do_rsync(self, line): """rsync [-m|--mirror] [-n|--dry-run] [-q|--quiet] SRC_DIR DEST_DIR @@ -2950,6 +3067,68 @@ def do_rsync(self, line): rsync(src_dir, dst_dir, mirror=args.mirror, dry_run=args.dry_run, print_func=pf, recursed=False, sync_hidden=args.all) + def complete_mv(self, text, line, begidx, endidx): + return self.filename_complete(text, line, begidx, endidx) + + def do_mv(self, line): + """usage: mv OLD_FILENAME NEW_FILENAME + + Renames a file or directory. + """ + args = self.line_to_args(line) + filenames = [arg for arg in args] + if len(filenames) != 2: + print_err('You must specify both the old and the new names') + return + old_filename = resolve_path(filenames[0]) + new_filename = resolve_path(filenames[1]) + old_mode = auto(get_mode, old_filename) + new_mode = auto(get_mode, new_filename) + if not mode_exists(old_mode): + print_err("Cannot access '{}'".format(old_filename)) + return + if mode_exists(new_mode): + print_err("Destination '{}' already exists".format(new_filename)) + return + mv(old_filename, new_filename) + + + +def load_macros(mod_name=None): + """Update the global macros dict. + Validate on import to avoid runtime errors as far as possible. + """ + default = mod_name is None + if default: + mod_name = MACFILE_NAME + try: + mmod = importlib.import_module(mod_name) + except ImportError: + if not default: + print("Can't import macro module", mod_name) + return False + except: + print("Macro module {} is invalid".format(mod_name)) + return False + + if hasattr(mmod, 'macros') and isinstance(mmod.macros, dict): + md = mmod.macros + else: + print('Macro module {} has missing or invalid dict.'.format(mod_name)) + return False + for k, v in md.items(): + if isinstance(v, str): + s = v + elif isinstance(v, tuple) or isinstance(v, list): + s = v[0] + else: + print('Macro {} is invalid.'.format(k)) + return False + if '\n' in s: + print('Invalid multi-line macro {} {}'.format(k, s)) + return False + macros.update(md) + return True def real_main(): """The main program.""" @@ -3037,6 +3216,11 @@ def real_main(): dest="filename", help="Specifies a file of commands to process." ) + parser.add_argument( + "-m", "--macros", + dest="macro_module", + help="Specify a macro module." + ) parser.add_argument( "-d", "--debug", dest="debug", @@ -3144,6 +3328,12 @@ def real_main(): global FAKE_INPUT_PROMPT FAKE_INPUT_PROMPT = True + if load_macros(): # Attempt to load default macro module + print('Default macro file {} loaded OK.'.format(MACFILE_NAME)) + if args.macro_module: # Attempt to load a macro module + if load_macros(args.macro_module): + print('Macro file {} loaded OK.'.format(args.macro_module)) + global ASCII_XFER ASCII_XFER = args.ascii_xfer RTS = args.rts