Skip to content

Commit 398cec2

Browse files
authored
Merge pull request #99 from python-cmd2/tab_completion
Tab completion
2 parents e3fd17d + d9de552 commit 398cec2

File tree

4 files changed

+191
-34
lines changed

4 files changed

+191
-34
lines changed

CHANGES.md

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ News
2222
* Example of how to use regular expressions in a transcript test
2323
* Added CmdResult namedtumple for returning and storing results
2424
* Added local file system path completion for ``edit``, ``load``, ``save``, and ``shell`` commands
25+
* Add shell command completion for ``shell`` command or ``!`` shortcut
2526

2627
0.7.0
2728
-----

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ cmd2 provides the following features, in addition to those already existing in c
3535
- Pipe output to shell commands with `|`
3636
- Simple transcript-based application testing
3737
- Unicode character support (*Python 3 only*)
38+
- Path completion for ``edit``, ``load``, ``save``, and ``shell`` commands
3839

3940
Instructions for implementing each feature follow.
4041

cmd2.py

+186-22
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
# Try to import readline, but allow failure for convenience in Windows unit testing
8383
# Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows
8484
try:
85+
# noinspection PyUnresolvedReferences
8586
import readline
8687
except ImportError:
8788
pass
@@ -479,6 +480,7 @@ class StubbornDict(dict):
479480
480481
Create it with the stubbornDict(arg) factory function.
481482
"""
483+
# noinspection PyMethodOverriding
482484
def update(self, arg):
483485
"""Adds dictionary arg's key-values pairs in to dict
484486
@@ -929,6 +931,33 @@ def precmd(self, statement):
929931
"""
930932
return statement
931933

934+
def parseline(self, line):
935+
"""Parse the line into a command name and a string containing the arguments.
936+
937+
Used for command tab completion. Returns a tuple containing (command, args, line).
938+
'command' and 'args' may be None if the line couldn't be parsed.
939+
940+
:param line: str - line read by readline
941+
:return: (str, str, str) - tuple containing (command, args, line)
942+
"""
943+
line = line.strip()
944+
945+
if not line:
946+
# Deal with empty line or all whitespace line
947+
return None, None, line
948+
949+
# Expand command shortcuts to the full command name
950+
for (shortcut, expansion) in self.shortcuts:
951+
if line.startswith(shortcut):
952+
line = line.replace(shortcut, expansion + ' ', 1)
953+
break
954+
955+
i, n = 0, len(line)
956+
while i < n and line[i] in self.identchars:
957+
i += 1
958+
command, arg = line[:i], line[i:].strip()
959+
return command, arg, line
960+
932961
def onecmd_plus_hooks(self, line):
933962
"""Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.
934963
@@ -1327,48 +1356,183 @@ def help_shell(self):
13271356
Usage: shell cmd"""
13281357
self.stdout.write("{}\n".format(help_str))
13291358

1330-
@staticmethod
1331-
def path_complete(line):
1359+
def path_complete(self, text, line, begidx, endidx, dir_exe_only=False, dir_only=False):
13321360
"""Method called to complete an input line by local file system path completion.
13331361
1362+
:param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
13341363
:param line: str - the current input line with leading whitespace removed
1364+
:param begidx: int - the beginning indexe of the prefix text
1365+
:param endidx: int - the ending index of the prefix text
1366+
:param dir_exe_only: bool - only return directories and executables, not non-executable files
1367+
:param dir_only: bool - only return directories
13351368
:return: List[str] - a list of possible tab completions
13361369
"""
1337-
path = line.split()[-1]
1338-
if not path:
1339-
path = '.'
1370+
# Deal with cases like load command and @ key when path completion is immediately after a shortcut
1371+
for (shortcut, expansion) in self.shortcuts:
1372+
if line.startswith(shortcut):
1373+
# If the next character after the shortcut isn't a space, then insert one and adjust indices
1374+
shortcut_len = len(shortcut)
1375+
if len(line) == shortcut_len or line[shortcut_len] != ' ':
1376+
line = line.replace(shortcut, shortcut + ' ', 1)
1377+
begidx += 1
1378+
endidx += 1
1379+
break
13401380

1341-
dirname, rest = os.path.split(path)
1342-
real_dir = os.path.expanduser(dirname)
1381+
# Determine if a trailing separator should be appended to directory completions
1382+
add_trailing_sep_if_dir = False
1383+
if endidx == len(line) or (endidx < len(line) and line[endidx] != os.path.sep):
1384+
add_trailing_sep_if_dir = True
13431385

1344-
# Find all matching path completions
1345-
path_completions = glob.glob(os.path.join(real_dir, rest) + '*')
1386+
add_sep_after_tilde = False
1387+
# If no path and no search text has been entered, then search in the CWD for *
1388+
if not text and line[begidx - 1] == ' ' and (begidx >= len(line) or line[begidx] == ' '):
1389+
search_str = os.path.join(os.getcwd(), '*')
1390+
else:
1391+
# Parse out the path being searched
1392+
prev_space_index = line.rfind(' ', 0, begidx)
1393+
dirname = line[prev_space_index + 1:begidx]
13461394

1347-
# Strip off everything but the final part of the completion because that's the way readline works
1348-
completions = [os.path.basename(c) for c in path_completions]
1395+
# Purposely don't match any path containing wildcards - what we are doing is complicated enough!
1396+
wildcards = ['*', '?']
1397+
for wildcard in wildcards:
1398+
if wildcard in dirname or wildcard in text:
1399+
return []
13491400

1350-
# If there is a single completion and it is a directory, add the final separator for convenience
1351-
if len(completions) == 1 and os.path.isdir(path_completions[0]):
1352-
completions[0] += os.path.sep
1401+
if not dirname:
1402+
dirname = os.getcwd()
1403+
elif dirname == '~':
1404+
# If tilde was used without separator, add a separator after the tilde in the completions
1405+
add_sep_after_tilde = True
1406+
1407+
# Build the search string
1408+
search_str = os.path.join(dirname, text + '*')
1409+
1410+
# Expand "~" to the real user directory
1411+
search_str = os.path.expanduser(search_str)
1412+
1413+
# Find all matching path completions
1414+
path_completions = glob.glob(search_str)
1415+
1416+
# If we only want directories and executables, filter everything else out first
1417+
if dir_exe_only:
1418+
path_completions = [c for c in path_completions if os.path.isdir(c) or os.access(c, os.X_OK)]
1419+
elif dir_only:
1420+
path_completions = [c for c in path_completions if os.path.isdir(c)]
1421+
1422+
# Get the basename of the paths
1423+
completions = []
1424+
for c in path_completions:
1425+
basename = os.path.basename(c)
1426+
1427+
# Add a separator after directories if the next character isn't already a separator
1428+
if os.path.isdir(c) and add_trailing_sep_if_dir:
1429+
basename += os.path.sep
1430+
1431+
completions.append(basename)
1432+
1433+
# If there is a single completion
1434+
if len(completions) == 1:
1435+
# If it is a file and we are at the end of the line, then add a space for convenience
1436+
if os.path.isfile(path_completions[0]) and endidx == len(line):
1437+
completions[0] += ' '
1438+
# If tilde was expanded without a separator, prepend one
1439+
elif os.path.isdir(path_completions[0]) and add_sep_after_tilde:
1440+
completions[0] = os.path.sep + completions[0]
13531441

13541442
return completions
13551443

1444+
# Enable tab completion of paths for relevant commands
1445+
complete_edit = path_complete
1446+
complete_load = path_complete
1447+
complete_save = path_complete
1448+
1449+
@staticmethod
1450+
def _shell_command_complete(search_text):
1451+
"""Method called to complete an input line by environment PATH executable completion.
1452+
1453+
:param search_text: str - the search text used to find a shell command
1454+
:return: List[str] - a list of possible tab completions
1455+
"""
1456+
1457+
# Purposely don't match any executable containing wildcards
1458+
wildcards = ['*', '?']
1459+
for wildcard in wildcards:
1460+
if wildcard in search_text:
1461+
return []
1462+
1463+
# Get a list of every directory in the PATH environment variable and ignore symbolic links
1464+
paths = [p for p in os.getenv('PATH').split(':') if not os.path.islink(p)]
1465+
1466+
# Find every executable file in the PATH that matches the pattern
1467+
exes = []
1468+
for path in paths:
1469+
full_path = os.path.join(path, search_text)
1470+
matches = [f for f in glob.glob(full_path + '*') if os.path.isfile(f) and os.access(f, os.X_OK)]
1471+
1472+
for match in matches:
1473+
exes.append(os.path.basename(match))
1474+
1475+
# If there is a single completion, then add a space at the end for convenience since
1476+
# this will be printed to the command line the user is typing
1477+
if len(exes) == 1:
1478+
exes[0] += ' '
1479+
1480+
return exes
1481+
13561482
# noinspection PyUnusedLocal
13571483
def complete_shell(self, text, line, begidx, endidx):
1358-
"""Handles tab completion of local file system paths.
1484+
"""Handles tab completion of executable commands and local file system paths.
13591485
13601486
:param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
13611487
:param line: str - the current input line with leading whitespace removed
1362-
:param begidx: str - the beginning indexe of the prefix text
1363-
:param endidx: str - the ending index of the prefix text
1488+
:param begidx: int - the beginning index of the prefix text
1489+
:param endidx: int - the ending index of the prefix text
13641490
:return: List[str] - a list of possible tab completions
13651491
"""
1366-
return self.path_complete(line)
13671492

1368-
# Enable tab completion of paths for other commands in an identical fashion
1369-
complete_edit = complete_shell
1370-
complete_load = complete_shell
1371-
complete_save = complete_shell
1493+
# First we strip off the shell command or shortcut key
1494+
if line.startswith('!'):
1495+
stripped_line = line.lstrip('!')
1496+
initial_length = len('!')
1497+
else:
1498+
stripped_line = line[len('shell'):]
1499+
initial_length = len('shell')
1500+
1501+
line_parts = stripped_line.split()
1502+
1503+
# Don't tab complete anything if user only typed shell or !
1504+
if not line_parts:
1505+
return []
1506+
1507+
# Find the start index of the first thing after the shell or !
1508+
cmd_start = line.find(line_parts[0], initial_length)
1509+
cmd_end = cmd_start + len(line_parts[0])
1510+
1511+
# Check if we are in the command token
1512+
if cmd_start <= begidx <= cmd_end:
1513+
1514+
# See if text is part of a path
1515+
possible_path = line[cmd_start:begidx]
1516+
1517+
# There is nothing to search
1518+
if len(possible_path) == 0 and not text:
1519+
return []
1520+
1521+
if os.path.sep not in possible_path:
1522+
# The text before the search text is not a directory path.
1523+
# It is OK to try shell command completion.
1524+
command_completions = self._shell_command_complete(text)
1525+
1526+
if command_completions:
1527+
return command_completions
1528+
1529+
# If we have no results, try path completion
1530+
return self.path_complete(text, line, begidx, endidx, dir_exe_only=True)
1531+
1532+
# Past command token
1533+
else:
1534+
# Do path completion
1535+
return self.path_complete(text, line, begidx, endidx)
13721536

13731537
def do_py(self, arg):
13741538
"""

examples/python_scripting.py

+3-12
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
1515
This application and the "script_conditional.py" script serve as an example for one way in which this can be done.
1616
"""
17+
import functools
1718
import os
1819

1920
from cmd2 import Cmd, options, make_option, CmdResult, set_use_arg_list
@@ -35,7 +36,6 @@ def __init__(self):
3536
def _set_prompt(self):
3637
"""Set prompt so it displays the current working directory."""
3738
self.cwd = os.getcwd()
38-
self.subdirs = [d for d in os.listdir(self.cwd) if os.path.isdir(d)]
3939
self.prompt = '{!r} $ '.format(self.cwd)
4040

4141
def postcmd(self, stop, line):
@@ -83,17 +83,8 @@ def do_cd(self, arg, opts=None):
8383
self.perror(err, traceback_war=False)
8484
self._last_result = CmdResult(out, err)
8585

86-
# noinspection PyUnusedLocal
87-
def complete_cd(self, text, line, begidx, endidx):
88-
"""Handles completion of arguments for the cd command.
89-
90-
:param text: str - the string prefix we are attempting to match (all returned matches must begin with it)
91-
:param line: str - the current input line with leading whitespace removed
92-
:param begidx: str - the beginning indexe of the prefix text
93-
:param endidx: str - the ending index of the prefix text
94-
:return: List[str] - a list of possible tab completions
95-
"""
96-
return [d for d in self.subdirs if d.startswith(text)]
86+
# Enable directory completion for cd command by freezing an argument to path_complete() with functools.partialmethod
87+
complete_cd = functools.partialmethod(Cmd.path_complete, dir_only=True)
9788

9889
@options([make_option('-l', '--long', action="store_true", help="display in long format with one item per line")],
9990
arg_desc='')

0 commit comments

Comments
 (0)