Skip to content

Commit 5aa4eb1

Browse files
committed
Merged from master and resolved conflicts
Also: - Fixed a couple warnings which showed up in VScode.
2 parents 68b8b61 + eb8181e commit 5aa4eb1

File tree

6 files changed

+167
-22
lines changed

6 files changed

+167
-22
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.8.8 (TBD, 2018)
2+
* Bug Fixes
3+
* Prevent crashes that could occur attempting to open a file in non-existent directory or with very long filename
4+
15
## 0.9.1 (May 28, 2018)
26
* Bug Fixes
37
* fix packaging error for 0.8.x versions (yes we had to deploy a new version

cmd2/argparse_completer.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str
5959

6060
import argparse
6161
from colorama import Fore
62+
import os
6263
import sys
6364
from typing import List, Dict, Tuple, Callable, Union
6465

@@ -78,6 +79,15 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str
7879
ACTION_SUPPRESS_HINT = 'suppress_hint'
7980

8081

82+
class CompletionItem(str):
83+
def __new__(cls, o, desc='', *args, **kwargs):
84+
return str.__new__(cls, o, *args, **kwargs)
85+
86+
# noinspection PyMissingConstructor,PyUnusedLocal
87+
def __init__(self, o, desc='', *args, **kwargs):
88+
self.description = desc
89+
90+
8191
class _RangeAction(object):
8292
def __init__(self, nargs: Union[int, str, Tuple[int, int], None]):
8393
self.nargs_min = None
@@ -413,6 +423,8 @@ def consume_positional_argument() -> None:
413423
completion_results = self._complete_for_arg(flag_action, text, line, begidx, endidx, consumed)
414424
if not completion_results:
415425
self._print_action_help(flag_action)
426+
elif len(completion_results) > 1:
427+
completion_results = self._format_completions(flag_action, completion_results)
416428

417429
# ok, we're not a flag, see if there's a positional argument to complete
418430
else:
@@ -422,9 +434,35 @@ def consume_positional_argument() -> None:
422434
completion_results = self._complete_for_arg(pos_action, text, line, begidx, endidx, consumed)
423435
if not completion_results:
424436
self._print_action_help(pos_action)
437+
elif len(completion_results) > 1:
438+
completion_results = self._format_completions(pos_action, completion_results)
425439

426440
return completion_results
427441

442+
def _format_completions(self, action, completions: List[Union[str, CompletionItem]]):
443+
if completions and len(completions) > 1 and isinstance(completions[0], CompletionItem):
444+
token_width = len(action.dest)
445+
completions_with_desc = []
446+
447+
for item in completions:
448+
if len(item) > token_width:
449+
token_width = len(item)
450+
451+
term_size = os.get_terminal_size()
452+
fill_width = int(term_size.columns * .6) - (token_width + 2)
453+
for item in completions:
454+
entry = '{: <{token_width}}{: <{fill_width}}'.format(item, item.description,
455+
token_width=token_width+2,
456+
fill_width=fill_width)
457+
completions_with_desc.append(entry)
458+
459+
header = '\n{: <{token_width}}{}'.format(action.dest.upper(), action.desc_header, token_width=token_width+2)
460+
461+
self._cmd2_app.completion_header = header
462+
self._cmd2_app.display_matches = completions_with_desc
463+
464+
return completions
465+
428466
def complete_command_help(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]:
429467
"""Supports the completion of sub-commands for commands through the cmd2 help command."""
430468
for idx, token in enumerate(tokens):

cmd2/cmd2.py

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -494,13 +494,20 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, persistent_histor
494494
# will be added if there is an unmatched opening quote
495495
self.allow_closing_quote = True
496496

497+
# An optional header that prints above the tab-completion suggestions
498+
self.completion_header = ''
499+
497500
# Use this list if you are completing strings that contain a common delimiter and you only want to
498501
# display the final portion of the matches as the tab-completion suggestions. The full matches
499502
# still must be returned from your completer function. For an example, look at path_complete()
500503
# which uses this to show only the basename of paths as the suggestions. delimiter_complete() also
501504
# populates this list.
502505
self.display_matches = []
503506

507+
# Used by functions like path_complete() and delimiter_complete() to properly
508+
# quote matches that are completed in a delimited fashion
509+
self.matches_delimited = False
510+
504511
# ----- Methods related to presenting output to the user -----
505512

506513
@property
@@ -657,7 +664,9 @@ def reset_completion_defaults(self):
657664
"""
658665
self.allow_appended_space = True
659666
self.allow_closing_quote = True
667+
self.completion_header = ''
660668
self.display_matches = []
669+
self.matches_delimited = False
661670

662671
if rl_type == RlType.GNU:
663672
readline.set_completion_display_matches_hook(self._display_matches_gnu_readline)
@@ -836,6 +845,8 @@ def delimiter_complete(self, text, line, begidx, endidx, match_against, delimite
836845

837846
# Display only the portion of the match that's being completed based on delimiter
838847
if matches:
848+
# Set this to True for proper quoting of matches with spaces
849+
self.matches_delimited = True
839850

840851
# Get the common beginning for the matches
841852
common_prefix = os.path.commonprefix(matches)
@@ -1037,6 +1048,9 @@ def complete_users():
10371048
search_str = os.path.join(os.getcwd(), search_str)
10381049
cwd_added = True
10391050

1051+
# Set this to True for proper quoting of paths with spaces
1052+
self.matches_delimited = True
1053+
10401054
# Find all matching path completions
10411055
matches = glob.glob(search_str)
10421056

@@ -1245,6 +1259,10 @@ def _display_matches_gnu_readline(self, substitution, matches, longest_match_len
12451259
strings_array[1:-1] = encoded_matches
12461260
strings_array[-1] = None
12471261

1262+
# Print the header if one exists
1263+
if self.completion_header:
1264+
sys.stdout.write('\n' + self.completion_header)
1265+
12481266
# Call readline's display function
12491267
# rl_display_match_list(strings_array, number of completion matches, longest match length)
12501268
readline_lib.rl_display_match_list(strings_array, len(encoded_matches), longest_match_length)
@@ -1270,6 +1288,10 @@ def _display_matches_pyreadline(self, matches): # pragma: no cover
12701288
# Add padding for visual appeal
12711289
matches_to_display, _ = self._pad_matches_to_display(matches_to_display)
12721290

1291+
# Print the header if one exists
1292+
if self.completion_header:
1293+
readline.rl.mode.console.write('\n' + self.completion_header)
1294+
12731295
# Display matches using actual display function. This also redraws the prompt and line.
12741296
orig_pyreadline_display(matches_to_display)
12751297

@@ -1309,7 +1331,7 @@ def complete(self, text, state):
13091331
# from text and update the indexes. This only applies if we are at the the beginning of the line.
13101332
shortcut_to_restore = ''
13111333
if begidx == 0:
1312-
for (shortcut, expansion) in self.shortcuts:
1334+
for (shortcut, _) in self.shortcuts:
13131335
if text.startswith(shortcut):
13141336
# Save the shortcut to restore later
13151337
shortcut_to_restore = shortcut
@@ -1414,13 +1436,7 @@ def complete(self, text, state):
14141436
display_matches_set = set(self.display_matches)
14151437
self.display_matches = list(display_matches_set)
14161438

1417-
# Check if display_matches has been used. If so, then matches
1418-
# on delimited strings like paths was done.
1419-
if self.display_matches:
1420-
matches_delimited = True
1421-
else:
1422-
matches_delimited = False
1423-
1439+
if not self.display_matches:
14241440
# Since self.display_matches is empty, set it to self.completion_matches
14251441
# before we alter them. That way the suggestions will reflect how we parsed
14261442
# the token being completed and not how readline did.
@@ -1435,7 +1451,7 @@ def complete(self, text, state):
14351451
# This is the tab completion text that will appear on the command line.
14361452
common_prefix = os.path.commonprefix(self.completion_matches)
14371453

1438-
if matches_delimited:
1454+
if self.matches_delimited:
14391455
# Check if any portion of the display matches appears in the tab completion
14401456
display_prefix = os.path.commonprefix(self.display_matches)
14411457

@@ -1696,7 +1712,7 @@ def onecmd_plus_hooks(self, line):
16961712
if self.timing:
16971713
self.pfeedback('Elapsed: %s' % str(datetime.datetime.now() - timestart))
16981714
finally:
1699-
if self.allow_redirection:
1715+
if self.allow_redirection and self.redirecting:
17001716
self._restore_output(statement)
17011717
except EmptyStatement:
17021718
pass
@@ -1840,7 +1856,11 @@ def _redirect_output(self, statement):
18401856
# REDIRECTION_APPEND or REDIRECTION_OUTPUT
18411857
if statement.output == constants.REDIRECTION_APPEND:
18421858
mode = 'a'
1843-
sys.stdout = self.stdout = open(statement.output_to, mode)
1859+
try:
1860+
sys.stdout = self.stdout = open(statement.output_to, mode)
1861+
except OSError as ex:
1862+
self.perror('Not Redirecting because - {}'.format(ex), traceback_war=False)
1863+
self.redirecting = False
18441864
else:
18451865
# going to a paste buffer
18461866
sys.stdout = self.stdout = tempfile.TemporaryFile(mode="w+")
@@ -2366,7 +2386,7 @@ def select(self, opts, prompt='Your choice? '):
23662386
fulloptions.append((opt[0], opt[1]))
23672387
except IndexError:
23682388
fulloptions.append((opt[0], opt[0]))
2369-
for (idx, (value, text)) in enumerate(fulloptions):
2389+
for (idx, (_, text)) in enumerate(fulloptions):
23702390
self.poutput(' %2d. %s\n' % (idx + 1, text))
23712391
while True:
23722392
response = input(prompt)
@@ -2878,16 +2898,19 @@ def _generate_transcript(self, history, transcript_file):
28782898
self.echo = saved_echo
28792899

28802900
# finally, we can write the transcript out to the file
2881-
with open(transcript_file, 'w') as fout:
2882-
fout.write(transcript)
2883-
2884-
# and let the user know what we did
2885-
if len(history) > 1:
2886-
plural = 'commands and their outputs'
2901+
try:
2902+
with open(transcript_file, 'w') as fout:
2903+
fout.write(transcript)
2904+
except OSError as ex:
2905+
self.perror('Failed to save transcript: {}'.format(ex), traceback_war=False)
28872906
else:
2888-
plural = 'command and its output'
2889-
msg = '{} {} saved to transcript file {!r}'
2890-
self.pfeedback(msg.format(len(history), plural, transcript_file))
2907+
# and let the user know what we did
2908+
if len(history) > 1:
2909+
plural = 'commands and their outputs'
2910+
else:
2911+
plural = 'command and its output'
2912+
msg = '{} {} saved to transcript file {!r}'
2913+
self.pfeedback(msg.format(len(history), plural, transcript_file))
28912914

28922915
@with_argument_list
28932916
def do_edit(self, arglist):

examples/tab_autocompletion.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ def instance_query_actors(self) -> List[str]:
109109
"""Simulating a function that queries and returns a completion values"""
110110
return actors
111111

112+
def instance_query_movie_ids(self) -> List[str]:
113+
"""Demonstrates showing tabular hinting of tab completion information"""
114+
completions_with_desc = []
115+
116+
for movie_id, movie_entry in self.MOVIE_DATABASE.items():
117+
completions_with_desc.append(argparse_completer.CompletionItem(movie_id, movie_entry['title']))
118+
119+
setattr(self.vid_delete_movie_id, 'desc_header', 'Title')
120+
setattr(self.movies_delete_movie_id, 'desc_header', 'Title')
121+
122+
return completions_with_desc
123+
112124
# This demonstrates a number of customizations of the AutoCompleter version of ArgumentParser
113125
# - The help output will separately group required vs optional flags
114126
# - The help output for arguments with multiple flags or with append=True is more concise
@@ -253,6 +265,8 @@ def _do_vid_media_shows(self, args) -> None:
253265
('path_complete', [False, False]))
254266

255267
vid_movies_delete_parser = vid_movies_commands_subparsers.add_parser('delete')
268+
vid_delete_movie_id = vid_movies_delete_parser.add_argument('movie_id', help='Movie ID')
269+
setattr(vid_delete_movie_id, argparse_completer.ACTION_ARG_CHOICES, instance_query_movie_ids)
256270

257271
vid_shows_parser = video_types_subparsers.add_parser('shows')
258272
vid_shows_parser.set_defaults(func=_do_vid_media_shows)
@@ -328,6 +342,8 @@ def _do_media_shows(self, args) -> None:
328342
movies_add_parser.add_argument('actor', help='Actors', nargs='*')
329343

330344
movies_delete_parser = movies_commands_subparsers.add_parser('delete')
345+
movies_delete_movie_id = movies_delete_parser.add_argument('movie_id', help='Movie ID')
346+
setattr(movies_delete_movie_id, argparse_completer.ACTION_ARG_CHOICES, 'instance_query_movie_ids')
331347

332348
movies_load_parser = movies_commands_subparsers.add_parser('load')
333349
movie_file_action = movies_load_parser.add_argument('movie_file', help='Movie database')
@@ -362,7 +378,7 @@ def complete_media(self, text, line, begidx, endidx):
362378
'director': TabCompleteExample.static_list_directors, # static list
363379
'movie_file': (self.path_complete, [False, False])
364380
}
365-
completer = argparse_completer.AutoCompleter(TabCompleteExample.media_parser, arg_choices=choices)
381+
completer = argparse_completer.AutoCompleter(TabCompleteExample.media_parser, arg_choices=choices, cmd2_app=self)
366382

367383
tokens, _ = self.tokens_for_completion(line, begidx, endidx)
368384
results = completer.complete_command(tokens, text, line, begidx, endidx)

tests/test_cmd2.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,44 @@ def test_output_redirection(base_app):
615615
finally:
616616
os.remove(filename)
617617

618+
def test_output_redirection_to_nonexistent_directory(base_app):
619+
filename = '~/fakedir/this_does_not_exist.txt'
620+
621+
# Verify that writing to a file in a non-existent directory doesn't work
622+
run_cmd(base_app, 'help > {}'.format(filename))
623+
expected = normalize(BASE_HELP)
624+
with pytest.raises(FileNotFoundError):
625+
with open(filename) as f:
626+
content = normalize(f.read())
627+
assert content == expected
628+
629+
# Verify that appending to a file also works
630+
run_cmd(base_app, 'help history >> {}'.format(filename))
631+
expected = normalize(BASE_HELP + '\n' + HELP_HISTORY)
632+
with pytest.raises(FileNotFoundError):
633+
with open(filename) as f:
634+
content = normalize(f.read())
635+
assert content == expected
636+
637+
def test_output_redirection_to_too_long_filename(base_app):
638+
filename = '~/sdkfhksdjfhkjdshfkjsdhfkjsdhfkjdshfkjdshfkjshdfkhdsfkjhewfuihewiufhweiufhiweufhiuewhiuewhfiuwehfiuewhfiuewhfiuewhfiuewhiuewhfiuewhfiuewfhiuwehewiufhewiuhfiweuhfiuwehfiuewfhiuwehiuewfhiuewhiewuhfiuewhfiuwefhewiuhewiufhewiufhewiufhewiufhewiufhewiufhewiufhewiuhewiufhewiufhewiuheiufhiuewheiwufhewiufheiufheiufhieuwhfewiuhfeiufhiuewfhiuewheiwuhfiuewhfiuewhfeiuwfhewiufhiuewhiuewhfeiuwhfiuwehfuiwehfiuehiuewhfieuwfhieufhiuewhfeiuwfhiuefhueiwhfw'
639+
640+
# Verify that writing to a file in a non-existent directory doesn't work
641+
run_cmd(base_app, 'help > {}'.format(filename))
642+
expected = normalize(BASE_HELP)
643+
with pytest.raises(OSError):
644+
with open(filename) as f:
645+
content = normalize(f.read())
646+
assert content == expected
647+
648+
# Verify that appending to a file also works
649+
run_cmd(base_app, 'help history >> {}'.format(filename))
650+
expected = normalize(BASE_HELP + '\n' + HELP_HISTORY)
651+
with pytest.raises(OSError):
652+
with open(filename) as f:
653+
content = normalize(f.read())
654+
assert content == expected
655+
618656

619657
def test_feedback_to_output_true(base_app):
620658
base_app.feedback_to_output = True

tests/test_transcript.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,32 @@ def test_history_transcript(request, capsys):
154154

155155
assert transcript == expected
156156

157+
def test_history_transcript_bad_filename(request, capsys):
158+
app = CmdLineApp()
159+
app.stdout = StdOut()
160+
run_cmd(app, 'orate this is\na /multiline/\ncommand;\n')
161+
run_cmd(app, 'speak /tmp/file.txt is not a regex')
162+
163+
expected = r"""(Cmd) orate this is
164+
> a /multiline/
165+
> command;
166+
this is a \/multiline\/ command
167+
(Cmd) speak /tmp/file.txt is not a regex
168+
\/tmp\/file.txt is not a regex
169+
"""
170+
171+
# make a tmp file
172+
history_fname = '~/fakedir/this_does_not_exist.txt'
173+
174+
# tell the history command to create a transcript
175+
run_cmd(app, 'history -t "{}"'.format(history_fname))
176+
177+
# read in the transcript created by the history command
178+
with pytest.raises(FileNotFoundError):
179+
with open(history_fname) as f:
180+
transcript = f.read()
181+
assert transcript == expected
182+
157183
@pytest.mark.parametrize('expected, transformed', [
158184
# strings with zero or one slash or with escaped slashes means no regular
159185
# expression present, so the result should just be what re.escape returns.

0 commit comments

Comments
 (0)