Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

filename tab-completion in Cmd.cmd of Python

I'm working on making a command-line tool using Cmd.cmd of Python, and I want to add a "load" command with filename argument, which is supporting tab-completion.

Referring this and this, I mad a code like this:

import os, cmd, sys, yaml
import os.path as op
import glob as gb

def _complete_path(path):
    if op.isdir(path):
        return gb.glob(op.join(path, '*'))
    else:
        return gb.glob(path+'*')

class CmdHandler(cmd.Cmd):

    def do_load(self, filename):
        try:
            with open(filename, 'r') as f:
                self.cfg = yaml.load(f)
        except:
            print 'fail to load the file "{:}"'.format(filename)

    def complete_load(self, text, line, start_idx, end_idx):
        return _complete_path(text)

This works well for cwd, however, when I want to go into subdir, after subdir/ then the "text" of complete_load function becomes blank, so _complete_path func returns cwd again.

I don't know how to get the contents of subdir with tab-completion. Please help!

like image 865
jinserk Avatar asked May 30 '13 00:05

jinserk


3 Answers

Your primary issue is that the readline library is delimiting things based on it's default delimiter set:

import readline
readline.get_completer_delims()
# yields ' \t\n`~!@#$%^&*()-=+[{]}\\|;:\'",<>/?'

When tab completing for a file name I remove everything from this but whitespace.

import readline
readline.set_completer_delims(' \t\n')

After setting the delimiters, the 'text' parameter to your completion function should be more what you are expecting.

This also resolves commonly encountered issues with tab completion duplicating part of your text.

like image 197
jcombs Avatar answered Sep 28 '22 18:09

jcombs


Implementing filename completion with cmd is a bit tricky because the underlying readline library interprets special characters such as '/' and '-' (and others) as separators, and this sets which substring within the line is to be replaced by the completions.

For example,

> load /hom<tab>

calls complete_load() with

text='hom', line='load /hom', begidx=6, endidx=9
text is line[begidx:endidx]

'text' is not "/hom" because the readline library parsed the line and returns the string after the '/' separator. The complete_load() should return a list of completion strings that begin with "hom", not "/hom", since the completions will replace the substring starting at the begidx. If complete_load() function incorrectly returns ['/home'], the line becomes,

> load //home

which is not good.

Other characters are considered to be separators by readline, not just slashes, so you cannot assume the substring before 'text' is a parent directory. For example:

> load /home/mike/my-file<tab>

calls complete_load() with

text='file', line='load /home/mike/my-file', begidx=19, endidx=23

Assuming /home/mike contains the files my-file1 and my-file2, the completions should be ['file1', 'file2'], not ['my-file1', 'my-file2'], nor ['/home/mike/my-file1', '/home/mike/my-file2']. If you return the full paths, the result is:

> load /home/mike/my-file/home/mike/my-file1

The approach I took was to use the glob module to find the full paths. Glob works for absolute paths and relative paths. After finding the paths, I remove the "fixed" portion, which is the substring before the begidx.

First, parse the fixed portion argument, which is the substring between the space and the begidx.

index = line.rindex(' ', 0, begidx)  # -1 if not found
fixed = line[index + 1: begidx]

The argument is between the space and the end of the line. Append a star to make a glob search pattern.

I append a '/' to results which are directories, as this makes it easier to traverse directories with tab completion (otherwise you need to hit the tab key twice for each directory), and it makes it obvious to the user which completion items are directories and which are files.

Finally remove the "fixed" portion of the paths, so readline will replace just the "text" part.

import os
import glob
import cmd

def _append_slash_if_dir(p):
    if p and os.path.isdir(p) and p[-1] != os.sep:
        return p + os.sep
    else:
        return p

class MyShell(cmd.Cmd):
    prompt = "> "

    def do_quit(self, line):
        return True

    def do_load(self, line):
        print("load " + line)

    def complete_load(self, text, line, begidx, endidx):
        before_arg = line.rfind(" ", 0, begidx)
        if before_arg == -1:
            return # arg not found

        fixed = line[before_arg+1:begidx]  # fixed portion of the arg
        arg = line[before_arg+1:endidx]
        pattern = arg + '*'

        completions = []
        for path in glob.glob(pattern):
            path = _append_slash_if_dir(path)
            completions.append(path.replace(fixed, "", 1))
        return completions

MyShell().cmdloop()
like image 35
meffie Avatar answered Sep 28 '22 17:09

meffie


I don't think this is the best answer, but I got the function what I intend to like this:

def _complete_path(text, line):
    arg = line.split()[1:]
    dir, base = '', ''
    try: 
        dir, base = op.split(arg[-1])
    except:
        pass
    cwd = os.getcwd()
    try: 
        os.chdir(dir)
    except:
        pass
    ret = [f+os.sep if op.isdir(f) else f for f in os.listdir('.') if f.startswith(base)]
    if base == '' or base == '.': 
        ret.extend(['./', '../'])
    elif base == '..':
        ret.append('../')
    os.chdir(cwd)
    return ret

    .............................

    def complete_load(self, text, line, start_idx, end_idx):
        return _complete_path(text, line)

I didn't use "text" from the complete_cmd(), but use a parsing "line" argument directly. If you have any better idea, please let me know.

like image 45
jinserk Avatar answered Sep 28 '22 16:09

jinserk