From c539ef4065e9ae8611e1851eaa740f6412f08c25 Mon Sep 17 00:00:00 2001 From: kotfu Date: Thu, 3 May 2018 10:02:21 -0700 Subject: Fix commented out transcript tests --- tests/test_transcript.py | 7 +++---- tests/transcripts/from_cmdloop.txt | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_transcript.py b/tests/test_transcript.py index c0fb49c1..c5263c03 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -26,7 +26,6 @@ class CmdLineApp(cmd2.Cmd): def __init__(self, *args, **kwargs): self.multiline_commands = ['orate'] self.maxrepeats = 3 - self.redirector = '->' # Add stuff to settable and/or shortcuts before calling base class initializer self.settable['maxrepeats'] = 'Max number of `--repeat`s allowed' @@ -63,7 +62,7 @@ class CmdLineApp(cmd2.Cmd): def do_mumble(self, opts, arg): """Mumbles what you tell me to.""" repetitions = opts.repeat or 1 - arg = arg.split() + #arg = arg.split() for i in range(min(repetitions, self.maxrepeats)): output = [] if random.random() < .33: @@ -229,7 +228,7 @@ def test_invalid_syntax(_cmdline_app, capsys): ('characterclass.txt', False), ('dotstar.txt', False), ('extension_notation.txt', False), - # ('from_cmdloop.txt', True), + ('from_cmdloop.txt', True), ('multiline_no_regex.txt', False), ('multiline_regex.txt', False), ('regex_set.txt', False), @@ -237,7 +236,7 @@ def test_invalid_syntax(_cmdline_app, capsys): ('slashes_escaped.txt', False), ('slashslash.txt', False), ('spaces.txt', False), - # ('word_boundaries.txt', False), + ('word_boundaries.txt', False), ]) def test_transcript(request, capsys, filename, feedback_to_output): # Create a cmd2.Cmd() instance and make sure basic settings are diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index 13b61b00..5f22d756 100644 --- a/tests/transcripts/from_cmdloop.txt +++ b/tests/transcripts/from_cmdloop.txt @@ -19,7 +19,6 @@ optional arguments:/ */ -s, --shout N00B EMULATION MODE/ */ -r REPEAT, --repeat REPEAT/ */ output [n] times - (Cmd) say goodnight, Gracie goodnight, Gracie (Cmd) say -ps --repeat=5 goodnight, Gracie -- cgit v1.2.1 From 6d2f520b5a5a7165f2406816592b01983b1feb26 Mon Sep 17 00:00:00 2001 From: kotfu Date: Thu, 3 May 2018 10:43:49 -0700 Subject: Moved some argparse tests from test_transcript.py to test_argparse.py --- tests/test_argparse.py | 13 +++++++++++++ tests/test_transcript.py | 31 ------------------------------- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 94a7b5ed..f1a2b357 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -119,6 +119,11 @@ def argparse_app(): return app +def test_invalid_syntax(argparse_app, capsys): + run_cmd(argparse_app, 'speak "') + out, err = capsys.readouterr() + assert err == "ERROR: Invalid syntax: No closing quotation\n" + def test_argparse_basic_command(argparse_app): out = run_cmd(argparse_app, 'say hello') assert out == ['hello'] @@ -135,6 +140,14 @@ def test_argparse_with_list_and_empty_doc(argparse_app): out = run_cmd(argparse_app, 'speak -s hello world!') assert out == ['HELLO WORLD!'] +def test_argparse_comment_stripping(argparse_app): + out = run_cmd(argparse_app, 'speak it was /* not */ delicious! # Yuck!') + assert out == ['it was delicious!'] + +def test_argparser_correct_args_with_quotes_and_midline_options(argparse_app): + out = run_cmd(argparse_app, "speak 'This is a' -s test of the emergency broadcast system!") + assert out == ['THIS IS A TEST OF THE EMERGENCY BROADCAST SYSTEM!'] + def test_argparse_quoted_arguments_multiple(argparse_app): out = run_cmd(argparse_app, 'say "hello there" "rick & morty"') assert out == ['hello there rick & morty'] diff --git a/tests/test_transcript.py b/tests/test_transcript.py index c5263c03..b4e10b60 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -95,13 +95,6 @@ def _cmdline_app(): return c -@pytest.fixture -def _demo_app(): - c = DemoApp() - c.stdout = StdOut() - return c - - def _get_transcript_blocks(transcript): cmd = None expected = '' @@ -188,24 +181,6 @@ class TestMyAppCase(cmd2.Cmd2TestCase): CmdApp.testfiles = ['tests/transcript.txt'] -def test_comment_stripping(_cmdline_app): - out = run_cmd(_cmdline_app, 'speak it was /* not */ delicious! # Yuck!') - expected = normalize("""it was delicious!""") - assert out == expected - - -def test_argparser_correct_args_with_quotes_and_midline_options(_cmdline_app): - out = run_cmd(_cmdline_app, "speak 'This is a' -s test of the emergency broadcast system!") - expected = normalize("""THIS IS A TEST OF THE EMERGENCY BROADCAST SYSTEM!""") - assert out == expected - - -def test_argparser_options_with_spaces_in_quotes(_demo_app): - out = run_cmd(_demo_app, "hello foo -n 'Bugs Bunny' bar baz") - expected = normalize("""Hello Bugs Bunny""") - assert out == expected - - def test_commands_at_invocation(): testargs = ["prog", "say hello", "say Gracie", "quit"] expected = "This is an intro banner ...\nhello\nGracie\n" @@ -216,12 +191,6 @@ def test_commands_at_invocation(): out = app.stdout.buffer assert out == expected -def test_invalid_syntax(_cmdline_app, capsys): - run_cmd(_cmdline_app, 'speak "') - out, err = capsys.readouterr() - expected = normalize("""ERROR: Invalid syntax: No closing quotation""") - assert normalize(str(err)) == expected - @pytest.mark.parametrize('filename, feedback_to_output', [ ('bol_eol.txt', False), -- cgit v1.2.1 From 94156f8a78b74588275141d27c0b633455fa4fc0 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 3 May 2018 17:00:01 -0400 Subject: Figured out how to detect the second tab press. Writing parameter hinting to stderr to bypass bash completion handling. --- cmd2/argcomplete_bridge.py | 11 +++++------ cmd2/argparse_completer.py | 4 +++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cmd2/argcomplete_bridge.py b/cmd2/argcomplete_bridge.py index 583f3345..3d53132e 100644 --- a/cmd2/argcomplete_bridge.py +++ b/cmd2/argcomplete_bridge.py @@ -228,15 +228,14 @@ else: output_stream.write(ifs.join(completions).encode(argcomplete.sys_encoding)) elif outstr: # if there are no completions, but we got something from stdout, try to print help - # trick the bash completion into thinking there are 2 completions that are unlikely # to ever match. - outstr = outstr.replace('\n', ' ').replace('\t', ' ').replace(' ', ' ').strip() - # generate a filler entry that should always sort first - filler = ' {0:><{width}}'.format('', width=len(outstr)/2) - outstr = ifs.join([filler, outstr]) - output_stream.write(outstr.encode(argcomplete.sys_encoding)) + comp_type = int(os.environ["COMP_TYPE"]) + if comp_type == 63: # type is 63 for second tab press + print(outstr.rstrip(), file=argcomplete.debug_stream, end='') + + output_stream.write(ifs.join([ifs, ' ']).encode(argcomplete.sys_encoding)) else: # if completions is None we assume we don't know how to handle it so let bash # go forward with normal filesystem completion diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 4964b1ec..a8a0f24a 100755 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -877,7 +877,9 @@ class ACArgumentParser(argparse.ArgumentParser): return super(ACArgumentParser, self)._match_argument(action, arg_strings_pattern) - def _parse_known_args(self, arg_strings, namespace): + # This is the official python implementation with a 5 year old patch applied + # See the comment below describing the patch + def _parse_known_args(self, arg_strings, namespace): # pragma: no cover # replace arg strings that are file references if self.fromfile_prefix_chars is not None: arg_strings = self._read_args_from_files(arg_strings) -- cgit v1.2.1 From dedd045666fe7bc90e3ba5f629c6b74673ccfec6 Mon Sep 17 00:00:00 2001 From: kotfu Date: Thu, 3 May 2018 21:36:46 -0600 Subject: Unit test for history transcript generation --- tests/test_transcript.py | 139 ++++++++------------------------- tests/transcripts/expected_history.txt | 18 +++++ 2 files changed, 49 insertions(+), 108 deletions(-) create mode 100644 tests/transcripts/expected_history.txt diff --git a/tests/test_transcript.py b/tests/test_transcript.py index b4e10b60..3a8eee66 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -10,12 +10,13 @@ import os import sys import re import random +import tempfile from unittest import mock import pytest from cmd2 import cmd2 -from .conftest import run_cmd, StdOut, normalize +from .conftest import run_cmd, StdOut class CmdLineApp(cmd2.Cmd): @@ -76,111 +77,6 @@ class CmdLineApp(cmd2.Cmd): self.poutput(' '.join(output)) -class DemoApp(cmd2.Cmd): - hello_parser = argparse.ArgumentParser() - hello_parser.add_argument('-n', '--name', help="your name") - @cmd2.with_argparser_and_unknown_args(hello_parser) - def do_hello(self, opts, arg): - """Says hello.""" - if opts.name: - self.stdout.write('Hello {}\n'.format(opts.name)) - else: - self.stdout.write('Hello Nobody\n') - - -@pytest.fixture -def _cmdline_app(): - c = CmdLineApp() - c.stdout = StdOut() - return c - - -def _get_transcript_blocks(transcript): - cmd = None - expected = '' - for line in transcript.splitlines(): - if line.startswith('(Cmd) '): - if cmd is not None: - yield cmd, normalize(expected) - - cmd = line[6:] - expected = '' - else: - expected += line + '\n' - yield cmd, normalize(expected) - - -def test_base_with_transcript(_cmdline_app): - app = _cmdline_app - transcript = """ -(Cmd) help - -Documented commands (type help ): -======================================== -alias help load orate pyscript say shell speak -edit history mumble py quit set shortcuts unalias - -(Cmd) help say -usage: speak [-h] [-p] [-s] [-r REPEAT] - -Repeats what you tell me to. - -optional arguments: - -h, --help show this help message and exit - -p, --piglatin atinLay - -s, --shout N00B EMULATION MODE - -r REPEAT, --repeat REPEAT - output [n] times - -(Cmd) say goodnight, Gracie -goodnight, Gracie -(Cmd) say -ps --repeat=5 goodnight, Gracie -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -(Cmd) set maxrepeats 5 -maxrepeats - was: 3 -now: 5 -(Cmd) say -ps --repeat=5 goodnight, Gracie -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -(Cmd) history --------------------------[1] -help --------------------------[2] -help say --------------------------[3] -say goodnight, Gracie --------------------------[4] -say -ps --repeat=5 goodnight, Gracie --------------------------[5] -set maxrepeats 5 --------------------------[6] -say -ps --repeat=5 goodnight, Gracie -(Cmd) history -r 4 -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -(Cmd) set prompt "---> " -prompt - was: (Cmd) -now: ---> -""" - - for cmd, expected in _get_transcript_blocks(transcript): - out = run_cmd(app, cmd) - assert out == expected - - -class TestMyAppCase(cmd2.Cmd2TestCase): - CmdApp = CmdLineApp - CmdApp.testfiles = ['tests/transcript.txt'] - - def test_commands_at_invocation(): testargs = ["prog", "say hello", "say Gracie", "quit"] expected = "This is an intro banner ...\nhello\nGracie\n" @@ -191,8 +87,7 @@ def test_commands_at_invocation(): out = app.stdout.buffer assert out == expected - -@pytest.mark.parametrize('filename, feedback_to_output', [ +@pytest.mark.parametrize('filename,feedback_to_output', [ ('bol_eol.txt', False), ('characterclass.txt', False), ('dotstar.txt', False), @@ -231,6 +126,34 @@ def test_transcript(request, capsys, filename, feedback_to_output): assert err.startswith(expected_start) assert err.endswith(expected_end) +def test_history_transcript(request, capsys): + app = CmdLineApp() + app.stdout = StdOut() + run_cmd(app, 'help') + run_cmd(app, 'speak lots of wierd [ /tmp ]: chars?') + run_cmd(app, 'speak /this is not a regex/') + + # Get location of the expected transcript + test_dir = os.path.dirname(request.module.__file__) + expected_fname = os.path.join(test_dir, 'transcripts', 'expected_history.txt') + with open(expected_fname) as f: + lines = f.readlines() + # trim off the first 7 lines so we can have a comment in the + # expected_history.txt file explaining what it is + expected = ''.join(lines[7:]) + + # make a tmp file + fd, history_fname = tempfile.mkstemp(prefix='', suffix='.txt') + os.close(fd) + + # tell the history command to create a transcript + run_cmd(app, 'history -t "{}"'.format(history_fname)) + + # read in the transcript created by the history command + with open(history_fname) as f: + transcript = f.read() + + assert transcript == expected @pytest.mark.parametrize('expected, transformed', [ # strings with zero or one slash or with escaped slashes means no regular diff --git a/tests/transcripts/expected_history.txt b/tests/transcripts/expected_history.txt new file mode 100644 index 00000000..cfd1409d --- /dev/null +++ b/tests/transcripts/expected_history.txt @@ -0,0 +1,18 @@ +# this file contains the expected output of a 'history -t' command. +# Because the help command outputs trailing spaces, this file +# contains trailing spaces. Don't mess it up with your editor +# which may be configured to trim trailing spaces +# The first 7 lines of this file are stripped out by the +# test case before comparing the actual output with +# the contents of this file. +(Cmd) help + +Documented commands (type help ): +======================================== +alias help load orate pyscript say shell speak +edit history mumble py quit set shortcuts unalias + +(Cmd) speak lots of wierd [ /tmp ]: chars? +lots of wierd [ \/tmp ]: chars? +(Cmd) speak /this is not a regex/ +\/this is not a regex\/ -- cgit v1.2.1 From bf729520b88270df0a1e7b8faa79ac6784549d7b Mon Sep 17 00:00:00 2001 From: kotfu Date: Thu, 3 May 2018 23:15:53 -0600 Subject: Fix #384, multiline commands now appear properly in transcripts --- cmd2/cmd2.py | 23 +++++++++++++++++------ tests/test_transcript.py | 3 +-- tests/transcripts/expected_history.txt | 8 ++++---- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f4f30bd4..c9596edf 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3002,27 +3002,38 @@ a..b, a:b, a:, ..b items by indices (inclusive) except Exception as e: self.perror('Saving {!r} - {}'.format(args.output_file, e), traceback_war=False) elif args.transcript: + membuf = io.StringIO() + # Make sure echo is on so commands print to standard out saved_echo = self.echo - self.echo = True + self.echo = False # Redirect stdout to the transcript file saved_self_stdout = self.stdout - self.stdout = open(args.transcript, 'w') + self.stdout = membuf # Run all of the commands in the history with output redirected to transcript and echo on - self.runcmds_plus_hooks(history) + for history_item in history: + # write the command to the output stream + first = True + for line in history_item.splitlines(): + if first: + self.stdout.write('{}{}\n'.format(self.prompt, line)) + first = False + else: + self.stdout.write('{}{}\n'.format(self.continuation_prompt, line)) + self.onecmd_plus_hooks(history_item) # Restore stdout to its original state - self.stdout.close() + #self.stdout.close() self.stdout = saved_self_stdout # Set echo back to its original state self.echo = saved_echo # Post-process the file to escape un-escaped "/" regex escapes - with open(args.transcript, 'r') as fin: - data = fin.read() + membuf.seek(0) + data = membuf.read() post_processed_data = data.replace('/', '\/') with open(args.transcript, 'w') as fout: fout.write(post_processed_data) diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 3a8eee66..6e2b1cac 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -130,8 +130,7 @@ def test_history_transcript(request, capsys): app = CmdLineApp() app.stdout = StdOut() run_cmd(app, 'help') - run_cmd(app, 'speak lots of wierd [ /tmp ]: chars?') - run_cmd(app, 'speak /this is not a regex/') + run_cmd(app, 'orate this is\na multiline\ncommand;\n') # Get location of the expected transcript test_dir = os.path.dirname(request.module.__file__) diff --git a/tests/transcripts/expected_history.txt b/tests/transcripts/expected_history.txt index cfd1409d..55eceb81 100644 --- a/tests/transcripts/expected_history.txt +++ b/tests/transcripts/expected_history.txt @@ -12,7 +12,7 @@ Documented commands (type help ): alias help load orate pyscript say shell speak edit history mumble py quit set shortcuts unalias -(Cmd) speak lots of wierd [ /tmp ]: chars? -lots of wierd [ \/tmp ]: chars? -(Cmd) speak /this is not a regex/ -\/this is not a regex\/ +(Cmd) orate this is +> a multiline +> command; +this is a multiline command -- cgit v1.2.1 From 6efe7217a58ff1bfe54bea79a7aa35f08a114b5f Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 4 May 2018 14:03:54 -0400 Subject: Adds some semblance of testing for bash completion. Tests the completion logic in the argcomplete function but doesn't test actual completion in bash. --- cmd2/argcomplete_bridge.py | 4 +- tests/test_bashcompletion.py | 206 +++++++++++++++++++++++++++++++++++++++++++ tox.ini | 6 ++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 tests/test_bashcompletion.py diff --git a/cmd2/argcomplete_bridge.py b/cmd2/argcomplete_bridge.py index 3d53132e..2e3ddac4 100644 --- a/cmd2/argcomplete_bridge.py +++ b/cmd2/argcomplete_bridge.py @@ -4,7 +4,7 @@ try: # check if argcomplete is installed import argcomplete -except ImportError: +except ImportError: # pragma: no cover # not installed, skip the rest of the file pass @@ -70,7 +70,7 @@ else: break except ValueError: # ValueError can be caused by missing closing quote - if not quotes_to_try: + if not quotes_to_try: # pragma: no cover # Since we have no more quotes to try, something else # is causing the parsing error. Return None since # this means the line is malformed. diff --git a/tests/test_bashcompletion.py b/tests/test_bashcompletion.py new file mode 100644 index 00000000..de1d99fb --- /dev/null +++ b/tests/test_bashcompletion.py @@ -0,0 +1,206 @@ +# coding=utf-8 +""" +Unit/functional testing for argparse completer in cmd2 + +Copyright 2018 Eric Lin +Released under MIT license, see LICENSE file +""" +import os +import pytest +import sys +from typing import List + +from cmd2.argparse_completer import ACArgumentParser, AutoCompleter +from cmd2.argcomplete_bridge import CompletionFinder + + +actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew', + 'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac', + 'Lupita Nyong\'o', 'Andy Serkis', 'Liam Neeson', 'Ewan McGregor', 'Natalie Portman', + 'Jake Lloyd', 'Hayden Christensen', 'Christopher Lee'] + + +def query_actors() -> List[str]: + """Simulating a function that queries and returns a completion values""" + return actors + + +@pytest.fixture +def parser1(): + """creates a argparse object to test completion against""" + ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17'] + + def _do_media_movies(self, args) -> None: + if not args.command: + self.do_help('media movies') + else: + print('media movies ' + str(args.__dict__)) + + def _do_media_shows(self, args) -> None: + if not args.command: + self.do_help('media shows') + + if not args.command: + self.do_help('media shows') + else: + print('media shows ' + str(args.__dict__)) + + media_parser = ACArgumentParser(prog='media') + + media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type') + + movies_parser = media_types_subparsers.add_parser('movies') + movies_parser.set_defaults(func=_do_media_movies) + + movies_commands_subparsers = movies_parser.add_subparsers(title='Commands', dest='command') + + movies_list_parser = movies_commands_subparsers.add_parser('list') + + movies_list_parser.add_argument('-t', '--title', help='Title Filter') + movies_list_parser.add_argument('-r', '--rating', help='Rating Filter', nargs='+', + choices=ratings_types) + movies_list_parser.add_argument('-d', '--director', help='Director Filter') + movies_list_parser.add_argument('-a', '--actor', help='Actor Filter', action='append') + + movies_add_parser = movies_commands_subparsers.add_parser('add') + movies_add_parser.add_argument('title', help='Movie Title') + movies_add_parser.add_argument('rating', help='Movie Rating', choices=ratings_types) + movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2), required=True) + movies_add_parser.add_argument('actor', help='Actors', nargs='*') + + movies_commands_subparsers.add_parser('delete') + + shows_parser = media_types_subparsers.add_parser('shows') + shows_parser.set_defaults(func=_do_media_shows) + + shows_commands_subparsers = shows_parser.add_subparsers(title='Commands', dest='command') + + shows_commands_subparsers.add_parser('list') + + return media_parser + + +# noinspection PyShadowingNames +def test_bash_nocomplete(parser1): + completer = CompletionFinder() + result = completer(parser1, AutoCompleter(parser1)) + assert result is None + + +# save the real os.fdopen +os_fdopen = os.fdopen + + +def my_fdopen(fd, mode): + """mock fdopen that redirects 8 and 9 from argcomplete to stdin/stdout for testing""" + if fd > 7: + return os_fdopen(fd - 7, mode) + return os_fdopen(fd, mode) + + +# noinspection PyShadowingNames +def test_invalid_ifs(parser1, mock): + completer = CompletionFinder() + + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013\013'}) + + mock.patch.object(os, 'fdopen', my_fdopen) + + with pytest.raises(SystemExit): + completer(parser1, AutoCompleter(parser1), exit_method=sys.exit) + + +# noinspection PyShadowingNames +@pytest.mark.parametrize('comp_line, exp_out, exp_err', [ + ('media ', 'movies\013shows', ''), + ('media mo', 'movies', ''), + ('media movies add ', '\013\013 ', ''' +Hint: + TITLE Movie Title'''), + ('media movies list -a "J', '"John Boyega"\013"Jake Lloyd"', ''), + ('media movies list ', '', '') +]) +def test_commands(parser1, capfd, mock, comp_line, exp_out, exp_err): + completer = CompletionFinder() + + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013', + 'COMP_TYPE': '63', + 'COMP_LINE': comp_line, + 'COMP_POINT': str(len(comp_line))}) + + mock.patch.object(os, 'fdopen', my_fdopen) + + with pytest.raises(SystemExit): + choices = {'actor': query_actors, # function + } + autocompleter = AutoCompleter(parser1, arg_choices=choices) + completer(parser1, autocompleter, exit_method=sys.exit) + + out, err = capfd.readouterr() + assert out == exp_out + assert err == exp_err + + +def fdopen_fail_8(fd, mode): + """mock fdopen that forces failure if fd == 8""" + if fd == 8: + raise IOError() + return my_fdopen(fd, mode) + + +# noinspection PyShadowingNames +def test_fail_alt_stdout(parser1, mock): + completer = CompletionFinder() + + comp_line = 'media movies list ' + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013', + 'COMP_TYPE': '63', + 'COMP_LINE': comp_line, + 'COMP_POINT': str(len(comp_line))}) + mock.patch.object(os, 'fdopen', fdopen_fail_8) + + try: + choices = {'actor': query_actors, # function + } + autocompleter = AutoCompleter(parser1, arg_choices=choices) + completer(parser1, autocompleter, exit_method=sys.exit) + except SystemExit as err: + assert err.code == 1 + + +def fdopen_fail_9(fd, mode): + """mock fdopen that forces failure if fd == 9""" + if fd == 9: + raise IOError() + return my_fdopen(fd, mode) + + +# noinspection PyShadowingNames +def test_fail_alt_stderr(parser1, capfd, mock): + completer = CompletionFinder() + + comp_line = 'media movies add ' + exp_out = '\013\013 ' + exp_err = ''' +Hint: + TITLE Movie Title''' + + mock.patch.dict(os.environ, {'_ARGCOMPLETE': '1', + '_ARGCOMPLETE_IFS': '\013', + 'COMP_TYPE': '63', + 'COMP_LINE': comp_line, + 'COMP_POINT': str(len(comp_line))}) + mock.patch.object(os, 'fdopen', fdopen_fail_9) + + with pytest.raises(SystemExit): + choices = {'actor': query_actors, # function + } + autocompleter = AutoCompleter(parser1, arg_choices=choices) + completer(parser1, autocompleter, exit_method=sys.exit) + + out, err = capfd.readouterr() + assert out == exp_out + assert err == exp_err diff --git a/tox.ini b/tox.ini index e74ce16f..295a5a7e 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,7 @@ deps = pyperclip pytest pytest-cov + pytest-mock wcwidth commands = py.test {posargs} --cov @@ -25,6 +26,7 @@ deps = mock pyperclip pytest + pytest-mock wcwidth commands = py.test -v @@ -33,6 +35,7 @@ deps = mock pyperclip pyreadline + pytest-mock pytest commands = py.test -v @@ -42,6 +45,7 @@ deps = pyperclip pytest pytest-cov + pytest-mock wcwidth commands = py.test {posargs} --cov @@ -53,6 +57,7 @@ deps = pyperclip pyreadline pytest + pytest-mock pytest-cov commands = py.test {posargs} --cov @@ -62,6 +67,7 @@ commands = deps = pyperclip pytest + pytest-mock wcwidth commands = py.test -v -- cgit v1.2.1 From efc6ab8e9604aa321168f62c98c7470138621399 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 4 May 2018 14:14:15 -0400 Subject: Added argcomplete to unit test environment. Added exclusion for Windows --- cmd2/argcomplete_bridge.py | 2 +- tests/test_bashcompletion.py | 7 +++++-- tox.ini | 6 ++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd2/argcomplete_bridge.py b/cmd2/argcomplete_bridge.py index 2e3ddac4..a036af1e 100644 --- a/cmd2/argcomplete_bridge.py +++ b/cmd2/argcomplete_bridge.py @@ -70,7 +70,7 @@ else: break except ValueError: # ValueError can be caused by missing closing quote - if not quotes_to_try: # pragma: no cover + if not quotes_to_try: # pragma: no cover # Since we have no more quotes to try, something else # is causing the parsing error. Return None since # this means the line is malformed. diff --git a/tests/test_bashcompletion.py b/tests/test_bashcompletion.py index de1d99fb..da897bf7 100644 --- a/tests/test_bashcompletion.py +++ b/tests/test_bashcompletion.py @@ -11,8 +11,11 @@ import sys from typing import List from cmd2.argparse_completer import ACArgumentParser, AutoCompleter -from cmd2.argcomplete_bridge import CompletionFinder - +try: + from cmd2.argcomplete_bridge import CompletionFinder +except: + # Don't test if argcomplete isn't present (likely on Windows) + pytest.skip() actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac', diff --git a/tox.ini b/tox.ini index 295a5a7e..c7ccdeac 100644 --- a/tox.ini +++ b/tox.ini @@ -16,6 +16,7 @@ deps = pytest pytest-cov pytest-mock + argcomplete wcwidth commands = py.test {posargs} --cov @@ -27,6 +28,7 @@ deps = pyperclip pytest pytest-mock + argcomplete wcwidth commands = py.test -v @@ -35,7 +37,6 @@ deps = mock pyperclip pyreadline - pytest-mock pytest commands = py.test -v @@ -46,6 +47,7 @@ deps = pytest pytest-cov pytest-mock + argcomplete wcwidth commands = py.test {posargs} --cov @@ -57,7 +59,6 @@ deps = pyperclip pyreadline pytest - pytest-mock pytest-cov commands = py.test {posargs} --cov @@ -68,6 +69,7 @@ deps = pyperclip pytest pytest-mock + argcomplete wcwidth commands = py.test -v -- cgit v1.2.1 From 7d0782630dbc22c8222fbd9f57641d9d5e81c61f Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 4 May 2018 14:43:47 -0400 Subject: Maybe this will do the trick. --- tests/test_bashcompletion.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/test_bashcompletion.py b/tests/test_bashcompletion.py index da897bf7..03e4afbb 100644 --- a/tests/test_bashcompletion.py +++ b/tests/test_bashcompletion.py @@ -11,11 +11,27 @@ import sys from typing import List from cmd2.argparse_completer import ACArgumentParser, AutoCompleter + + try: from cmd2.argcomplete_bridge import CompletionFinder -except: + skip_reason1 = False + skip_reason = '' +except ImportError: # Don't test if argcomplete isn't present (likely on Windows) - pytest.skip() + skip_reason1 = True + skip_reason = "argcomplete isn't installed\n" + + +skip_reason2 = "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true" +if skip_reason2: + skip_reason += 'These tests cannot run on TRAVIS\n' +skip_reason3 = sys.platform.startswith('win') +if skip_reason3: + skip_reason = 'argcomplete doesn\'t support Windows' + +skip = skip_reason1 or skip_reason2 or skip_reason3 + actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac', @@ -84,6 +100,7 @@ def parser1(): # noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) def test_bash_nocomplete(parser1): completer = CompletionFinder() result = completer(parser1, AutoCompleter(parser1)) @@ -102,6 +119,7 @@ def my_fdopen(fd, mode): # noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) def test_invalid_ifs(parser1, mock): completer = CompletionFinder() @@ -115,6 +133,7 @@ def test_invalid_ifs(parser1, mock): # noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) @pytest.mark.parametrize('comp_line, exp_out, exp_err', [ ('media ', 'movies\013shows', ''), ('media mo', 'movies', ''), @@ -154,6 +173,7 @@ def fdopen_fail_8(fd, mode): # noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) def test_fail_alt_stdout(parser1, mock): completer = CompletionFinder() @@ -182,6 +202,7 @@ def fdopen_fail_9(fd, mode): # noinspection PyShadowingNames +@pytest.mark.skipif(skip, reason=skip_reason) def test_fail_alt_stderr(parser1, capfd, mock): completer = CompletionFinder() -- cgit v1.2.1 From ea5eb8e9bc957d4f9c211300103cc1a5d01c20f4 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 4 May 2018 14:54:26 -0400 Subject: Another attempt at getting it working on travis. --- examples/subcommands.py | 3 --- tests/test_bashcompletion.py | 19 ++++++++++--------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/examples/subcommands.py b/examples/subcommands.py index 9bf6c666..3dd2c683 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -41,9 +41,6 @@ try: from cmd2.argcomplete_bridge import CompletionFinder from cmd2.argparse_completer import AutoCompleter if __name__ == '__main__': - with open('out.txt', 'a') as f: - f.write('Here 1') - f.flush() completer = CompletionFinder() completer(base_parser, AutoCompleter(base_parser)) except ImportError: diff --git a/tests/test_bashcompletion.py b/tests/test_bashcompletion.py index 03e4afbb..6d6f5000 100644 --- a/tests/test_bashcompletion.py +++ b/tests/test_bashcompletion.py @@ -23,9 +23,10 @@ except ImportError: skip_reason = "argcomplete isn't installed\n" -skip_reason2 = "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true" -if skip_reason2: - skip_reason += 'These tests cannot run on TRAVIS\n' +# skip_reason2 = "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true" +# if skip_reason2: +# skip_reason += 'These tests cannot run on TRAVIS\n' +skip_reaason2 = False skip_reason3 = sys.platform.startswith('win') if skip_reason3: skip_reason = 'argcomplete doesn\'t support Windows' @@ -111,10 +112,10 @@ def test_bash_nocomplete(parser1): os_fdopen = os.fdopen -def my_fdopen(fd, mode): +def my_fdopen(fd, mode, *args): """mock fdopen that redirects 8 and 9 from argcomplete to stdin/stdout for testing""" if fd > 7: - return os_fdopen(fd - 7, mode) + return os_fdopen(fd - 7, mode, *args) return os_fdopen(fd, mode) @@ -165,11 +166,11 @@ def test_commands(parser1, capfd, mock, comp_line, exp_out, exp_err): assert err == exp_err -def fdopen_fail_8(fd, mode): +def fdopen_fail_8(fd, mode, *args): """mock fdopen that forces failure if fd == 8""" if fd == 8: raise IOError() - return my_fdopen(fd, mode) + return my_fdopen(fd, mode, *args) # noinspection PyShadowingNames @@ -194,11 +195,11 @@ def test_fail_alt_stdout(parser1, mock): assert err.code == 1 -def fdopen_fail_9(fd, mode): +def fdopen_fail_9(fd, mode, *args): """mock fdopen that forces failure if fd == 9""" if fd == 9: raise IOError() - return my_fdopen(fd, mode) + return my_fdopen(fd, mode, *args) # noinspection PyShadowingNames -- cgit v1.2.1 From 0f1283ea13d590c4ffa332c79ea6801b61babc77 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 4 May 2018 14:56:26 -0400 Subject: stupid typo. One more try. --- tests/test_bashcompletion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_bashcompletion.py b/tests/test_bashcompletion.py index 6d6f5000..1d8caba6 100644 --- a/tests/test_bashcompletion.py +++ b/tests/test_bashcompletion.py @@ -26,7 +26,7 @@ except ImportError: # skip_reason2 = "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true" # if skip_reason2: # skip_reason += 'These tests cannot run on TRAVIS\n' -skip_reaason2 = False +skip_reason2 = False skip_reason3 = sys.platform.startswith('win') if skip_reason3: skip_reason = 'argcomplete doesn\'t support Windows' -- cgit v1.2.1 From 3e0e3b1d38a202271d7e8356430c92d42e9c0c28 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 4 May 2018 15:02:48 -0400 Subject: OK, giving up. Disabling bash completion test on travis. --- tests/test_bashcompletion.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_bashcompletion.py b/tests/test_bashcompletion.py index 1d8caba6..22c6aa7d 100644 --- a/tests/test_bashcompletion.py +++ b/tests/test_bashcompletion.py @@ -22,11 +22,10 @@ except ImportError: skip_reason1 = True skip_reason = "argcomplete isn't installed\n" +skip_reason2 = "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true" +if skip_reason2: + skip_reason += 'These tests cannot run on TRAVIS\n' -# skip_reason2 = "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true" -# if skip_reason2: -# skip_reason += 'These tests cannot run on TRAVIS\n' -skip_reason2 = False skip_reason3 = sys.platform.startswith('win') if skip_reason3: skip_reason = 'argcomplete doesn\'t support Windows' -- cgit v1.2.1 From 23824e83fc835291c093738326c4d903dcc9b839 Mon Sep 17 00:00:00 2001 From: kotfu Date: Fri, 4 May 2018 15:32:05 -0600 Subject: Fix transcript generate to close #385 --- cmd2/cmd2.py | 56 +++++++++++++++++++++++----------- tests/test_transcript.py | 1 + tests/transcripts/expected_history.txt | 2 ++ 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c9596edf..b463ff48 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3002,45 +3002,65 @@ a..b, a:b, a:, ..b items by indices (inclusive) except Exception as e: self.perror('Saving {!r} - {}'.format(args.output_file, e), traceback_war=False) elif args.transcript: - membuf = io.StringIO() - - # Make sure echo is on so commands print to standard out + # Save the current echo state, and turn it off. We inject commands into the + # output using a different mechanism saved_echo = self.echo self.echo = False # Redirect stdout to the transcript file saved_self_stdout = self.stdout - self.stdout = membuf - # Run all of the commands in the history with output redirected to transcript and echo on + # The problem with supporting regular expressions in transcripts + # is that they shouldn't be processed in the command, just the output. + # In addition, when we generate a transcript, any slashes in the output + # are not really intended to indicate regular expressions, so they should + # be escaped. + # + # We have to jump through some hoops here in order to catch the commands + # separately from the output and escape the slashes in the output. + transcript = '' for history_item in history: - # write the command to the output stream + # build the command, complete with prompts. When we replay + # the transcript, it looks for the prompts to separate + # the command from the output first = True + command = '' for line in history_item.splitlines(): if first: - self.stdout.write('{}{}\n'.format(self.prompt, line)) + command += '{}{}\n'.format(self.prompt, line) first = False else: - self.stdout.write('{}{}\n'.format(self.continuation_prompt, line)) + command += '{}{}\n'.format(self.continuation_prompt, line) + transcript += command + # create a new string buffer and set it to stdout to catch the output + # of the command + membuf = io.StringIO() + self.stdout = membuf + # then run the command and let the output go into our buffer self.onecmd_plus_hooks(history_item) + # rewind the buffer to the beginning + membuf.seek(0) + # get the output out of the buffer + output = membuf.read() + # and add the regex-escaped output to the transcript + transcript += output.replace('/', '\/') # Restore stdout to its original state - #self.stdout.close() self.stdout = saved_self_stdout - # Set echo back to its original state self.echo = saved_echo - # Post-process the file to escape un-escaped "/" regex escapes - membuf.seek(0) - data = membuf.read() - post_processed_data = data.replace('/', '\/') + # finally, we can write the transcript out to the file with open(args.transcript, 'w') as fout: - fout.write(post_processed_data) + fout.write(transcript) - plural = 's' if len(history) > 1 else '' - self.pfeedback('{} command{} and outputs saved to transcript file {!r}'.format(len(history), plural, - args.transcript)) + # and let the user know what we did + if len(history) > 1: + plural = 'commands and their outputs' + else: + plural = 'command and its output' + msg = '{} {} saved to transcript file {!r}' + self.pfeedback(msg.format(len(history), plural, args.transcript)) else: # Display the history items retrieved for hi in history: diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 6e2b1cac..5fee1363 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -131,6 +131,7 @@ def test_history_transcript(request, capsys): app.stdout = StdOut() run_cmd(app, 'help') run_cmd(app, 'orate this is\na multiline\ncommand;\n') + run_cmd(app, 'speak /tmp/file.txt is not a regex') # Get location of the expected transcript test_dir = os.path.dirname(request.module.__file__) diff --git a/tests/transcripts/expected_history.txt b/tests/transcripts/expected_history.txt index 55eceb81..ba2d94ce 100644 --- a/tests/transcripts/expected_history.txt +++ b/tests/transcripts/expected_history.txt @@ -16,3 +16,5 @@ edit history mumble py quit set shortcuts unalias > a multiline > command; this is a multiline command +(Cmd) speak /tmp/file.txt is not a regex +\/tmp\/file.txt is not a regex -- cgit v1.2.1 From 6c33b5823c14286a3ec2ddac89561a62e164090b Mon Sep 17 00:00:00 2001 From: kotfu Date: Fri, 4 May 2018 16:56:53 -0600 Subject: Refactor transcript from history implementation and test --- cmd2/cmd2.py | 122 +++++++++++++++++---------------- tests/test_transcript.py | 18 +++-- tests/transcripts/expected_history.txt | 20 ------ 3 files changed, 71 insertions(+), 89 deletions(-) delete mode 100644 tests/transcripts/expected_history.txt diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index b463ff48..ad2038d4 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3002,65 +3002,7 @@ a..b, a:b, a:, ..b items by indices (inclusive) except Exception as e: self.perror('Saving {!r} - {}'.format(args.output_file, e), traceback_war=False) elif args.transcript: - # Save the current echo state, and turn it off. We inject commands into the - # output using a different mechanism - saved_echo = self.echo - self.echo = False - - # Redirect stdout to the transcript file - saved_self_stdout = self.stdout - - # The problem with supporting regular expressions in transcripts - # is that they shouldn't be processed in the command, just the output. - # In addition, when we generate a transcript, any slashes in the output - # are not really intended to indicate regular expressions, so they should - # be escaped. - # - # We have to jump through some hoops here in order to catch the commands - # separately from the output and escape the slashes in the output. - transcript = '' - for history_item in history: - # build the command, complete with prompts. When we replay - # the transcript, it looks for the prompts to separate - # the command from the output - first = True - command = '' - for line in history_item.splitlines(): - if first: - command += '{}{}\n'.format(self.prompt, line) - first = False - else: - command += '{}{}\n'.format(self.continuation_prompt, line) - transcript += command - # create a new string buffer and set it to stdout to catch the output - # of the command - membuf = io.StringIO() - self.stdout = membuf - # then run the command and let the output go into our buffer - self.onecmd_plus_hooks(history_item) - # rewind the buffer to the beginning - membuf.seek(0) - # get the output out of the buffer - output = membuf.read() - # and add the regex-escaped output to the transcript - transcript += output.replace('/', '\/') - - # Restore stdout to its original state - self.stdout = saved_self_stdout - # Set echo back to its original state - self.echo = saved_echo - - # finally, we can write the transcript out to the file - with open(args.transcript, 'w') as fout: - fout.write(transcript) - - # and let the user know what we did - if len(history) > 1: - plural = 'commands and their outputs' - else: - plural = 'command and its output' - msg = '{} {} saved to transcript file {!r}' - self.pfeedback(msg.format(len(history), plural, args.transcript)) + self._generate_transcript(history, args.transcript) else: # Display the history items retrieved for hi in history: @@ -3069,6 +3011,68 @@ a..b, a:b, a:, ..b items by indices (inclusive) else: self.poutput(hi.pr()) + def _generate_transcript(self, history, transcript_file): + """Generate a transcript file from a given history of commands.""" + # Save the current echo state, and turn it off. We inject commands into the + # output using a different mechanism + saved_echo = self.echo + self.echo = False + + # Redirect stdout to the transcript file + saved_self_stdout = self.stdout + + # The problem with supporting regular expressions in transcripts + # is that they shouldn't be processed in the command, just the output. + # In addition, when we generate a transcript, any slashes in the output + # are not really intended to indicate regular expressions, so they should + # be escaped. + # + # We have to jump through some hoops here in order to catch the commands + # separately from the output and escape the slashes in the output. + transcript = '' + for history_item in history: + # build the command, complete with prompts. When we replay + # the transcript, we look for the prompts to separate + # the command from the output + first = True + command = '' + for line in history_item.splitlines(): + if first: + command += '{}{}\n'.format(self.prompt, line) + first = False + else: + command += '{}{}\n'.format(self.continuation_prompt, line) + transcript += command + # create a new string buffer and set it to stdout to catch the output + # of the command + membuf = io.StringIO() + self.stdout = membuf + # then run the command and let the output go into our buffer + self.onecmd_plus_hooks(history_item) + # rewind the buffer to the beginning + membuf.seek(0) + # get the output out of the buffer + output = membuf.read() + # and add the regex-escaped output to the transcript + transcript += output.replace('/', '\/') + + # Restore stdout to its original state + self.stdout = saved_self_stdout + # Set echo back to its original state + self.echo = saved_echo + + # finally, we can write the transcript out to the file + with open(transcript_file, 'w') as fout: + fout.write(transcript) + + # and let the user know what we did + if len(history) > 1: + plural = 'commands and their outputs' + else: + plural = 'command and its output' + msg = '{} {} saved to transcript file {!r}' + self.pfeedback(msg.format(len(history), plural, transcript_file)) + @with_argument_list def do_edit(self, arglist): """Edit a file in a text editor. diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 5fee1363..70658161 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -129,18 +129,16 @@ def test_transcript(request, capsys, filename, feedback_to_output): def test_history_transcript(request, capsys): app = CmdLineApp() app.stdout = StdOut() - run_cmd(app, 'help') - run_cmd(app, 'orate this is\na multiline\ncommand;\n') + run_cmd(app, 'orate this is\na /multiline/\ncommand;\n') run_cmd(app, 'speak /tmp/file.txt is not a regex') - # Get location of the expected transcript - test_dir = os.path.dirname(request.module.__file__) - expected_fname = os.path.join(test_dir, 'transcripts', 'expected_history.txt') - with open(expected_fname) as f: - lines = f.readlines() - # trim off the first 7 lines so we can have a comment in the - # expected_history.txt file explaining what it is - expected = ''.join(lines[7:]) + expected = r"""(Cmd) orate this is +> a /multiline/ +> command; +this is a \/multiline\/ command +(Cmd) speak /tmp/file.txt is not a regex +\/tmp\/file.txt is not a regex +""" # make a tmp file fd, history_fname = tempfile.mkstemp(prefix='', suffix='.txt') diff --git a/tests/transcripts/expected_history.txt b/tests/transcripts/expected_history.txt deleted file mode 100644 index ba2d94ce..00000000 --- a/tests/transcripts/expected_history.txt +++ /dev/null @@ -1,20 +0,0 @@ -# this file contains the expected output of a 'history -t' command. -# Because the help command outputs trailing spaces, this file -# contains trailing spaces. Don't mess it up with your editor -# which may be configured to trim trailing spaces -# The first 7 lines of this file are stripped out by the -# test case before comparing the actual output with -# the contents of this file. -(Cmd) help - -Documented commands (type help ): -======================================== -alias help load orate pyscript say shell speak -edit history mumble py quit set shortcuts unalias - -(Cmd) orate this is -> a multiline -> command; -this is a multiline command -(Cmd) speak /tmp/file.txt is not a regex -\/tmp\/file.txt is not a regex -- cgit v1.2.1 From 7486bae77936f5168f3855d527dc8467d1132e4c Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 4 May 2018 21:52:34 -0400 Subject: Skip a couple tests on macOS which were problematic on my computer --- tests/test_bashcompletion.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_bashcompletion.py b/tests/test_bashcompletion.py index 22c6aa7d..ceae2aa9 100644 --- a/tests/test_bashcompletion.py +++ b/tests/test_bashcompletion.py @@ -32,6 +32,8 @@ if skip_reason3: skip = skip_reason1 or skip_reason2 or skip_reason3 +skip_mac = sys.platform.startswith('dar') + actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac', @@ -133,7 +135,7 @@ def test_invalid_ifs(parser1, mock): # noinspection PyShadowingNames -@pytest.mark.skipif(skip, reason=skip_reason) +@pytest.mark.skipif(skip or skip_mac, reason=skip_reason) @pytest.mark.parametrize('comp_line, exp_out, exp_err', [ ('media ', 'movies\013shows', ''), ('media mo', 'movies', ''), @@ -202,7 +204,7 @@ def fdopen_fail_9(fd, mode, *args): # noinspection PyShadowingNames -@pytest.mark.skipif(skip, reason=skip_reason) +@pytest.mark.skipif(skip or skip_mac, reason=skip_reason) def test_fail_alt_stderr(parser1, capfd, mock): completer = CompletionFinder() -- cgit v1.2.1 From 7cd126394e162a65675ce9c0a0f634323d7ed20f Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 5 May 2018 20:50:10 -0600 Subject: Rename some test functions to fix #388 --- tests/test_completion.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_completion.py b/tests/test_completion.py index a027d780..bda5bb8a 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -887,7 +887,7 @@ def test_cmd2_subcmd_with_unknown_completion_nomatch(scu_app): assert first_match is None -def test_cmd2_help_subcommand_completion_single(scu_app): +def test_cmd2_help_subcommand_completion_single_scu(scu_app): text = 'base' line = 'help {}'.format(text) endidx = len(line) @@ -895,7 +895,7 @@ def test_cmd2_help_subcommand_completion_single(scu_app): assert scu_app.complete_help(text, line, begidx, endidx) == ['base'] -def test_cmd2_help_subcommand_completion_multiple(scu_app): +def test_cmd2_help_subcommand_completion_multiple_scu(scu_app): text = '' line = 'help base {}'.format(text) endidx = len(line) @@ -905,7 +905,7 @@ def test_cmd2_help_subcommand_completion_multiple(scu_app): assert matches == ['bar', 'foo', 'sport'] -def test_cmd2_help_subcommand_completion_nomatch(scu_app): +def test_cmd2_help_subcommand_completion_nomatch_scu(scu_app): text = 'z' line = 'help base {}'.format(text) endidx = len(line) @@ -913,7 +913,7 @@ def test_cmd2_help_subcommand_completion_nomatch(scu_app): assert scu_app.complete_help(text, line, begidx, endidx) == [] -def test_subcommand_tab_completion(scu_app): +def test_subcommand_tab_completion_scu(scu_app): # This makes sure the correct completer for the sport subcommand is called text = 'Foot' line = 'base sport {}'.format(text) @@ -926,7 +926,7 @@ def test_subcommand_tab_completion(scu_app): assert first_match is not None and scu_app.completion_matches == ['Football '] -def test_subcommand_tab_completion_with_no_completer(scu_app): +def test_subcommand_tab_completion_with_no_completer_scu(scu_app): # This tests what happens when a subcommand has no completer # In this case, the foo subcommand has no completer defined text = 'Foot' @@ -938,7 +938,7 @@ def test_subcommand_tab_completion_with_no_completer(scu_app): assert first_match is None -def test_subcommand_tab_completion_space_in_text(scu_app): +def test_subcommand_tab_completion_space_in_text_scu(scu_app): text = 'B' line = 'base sport "Space {}'.format(text) endidx = len(line) -- cgit v1.2.1 From 19e1d228404432d9e2c386dd64bf24a58ddc2e8d Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 5 May 2018 21:34:07 -0600 Subject: Refactor self.complete() for #380 Use self.statement_parser() instead of self.parseline() --- cmd2/cmd2.py | 9 +++------ cmd2/parsing.py | 53 ++++++++++++++++++++++++++++++++++++++------------- tests/test_parsing.py | 44 +++++++++++++++++++++++------------------- 3 files changed, 67 insertions(+), 39 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ad2038d4..db4cef2e 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1577,12 +1577,9 @@ class Cmd(cmd.Cmd): if begidx > 0: # Parse the command line - command, args, expanded_line = self.parseline(line) - - # use these lines instead of the one above - # statement = self.command_parser.parse_command_only(line) - # command = statement.command - # expanded_line = statement.command_and_args + statement = self.statement_parser.parse_command_only(line) + command = statement.command + expanded_line = statement.command_and_args # We overwrote line with a properly formatted but fully stripped version # Restore the end spaces since line is only supposed to be lstripped when diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 908e9272..ccea18c9 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -144,18 +144,20 @@ class StatementParser(): # aliases have to be a word, so make a regular expression # that matches the first word in the line. This regex has two # parts, the first parenthesis enclosed group matches one - # or more non-whitespace characters, and the second group - # matches either a whitespace character or the end of the - # string. We use \A and \Z to ensure we always match the - # beginning and end of a string that may have multiple - # lines - self.command_pattern = re.compile(r'\A(\S+)(\s|\Z)') + # or more non-whitespace characters (which may be preceeded + # by whitespace) and the second group matches either a whitespace + # character or the end of the string. We use \A and \Z to ensure + # we always match the beginning and end of a string that may have + # multiple lines + self.command_pattern = re.compile(r'\A\s*(\S+)(\s|\Z)+') def tokenize(self, line: str) -> List[str]: """Lex a string into a list of tokens. Comments are removed, and shortcuts and aliases are expanded. + + Raises ValueError if there are unclosed quotation marks. """ # strip C-style comments @@ -177,6 +179,8 @@ class StatementParser(): """Tokenize the input and parse it into a Statement object, stripping comments, expanding aliases and shortcuts, and extracting output redirection directives. + + Raises ValueError if there are unclosed quotation marks. """ # handle the special case/hardcoded terminator of a blank line @@ -297,16 +301,40 @@ class StatementParser(): return statement def parse_command_only(self, rawinput: str) -> Statement: - """Partially parse input into a Statement object. The command is - identified, and shortcuts and aliases are expanded. + """Partially parse input into a Statement object. + + The command is identified, and shortcuts and aliases are expanded. Terminators, multiline commands, and output redirection are not parsed. + + This method is used by tab completion code and therefore must not + generate an exception if there are unclosed quotes. + + The Statement object returned by this method can at most contained + values in the following attributes: + - raw + - command + - args + + Different from parse(), this method does not remove redundant whitespace + within statement.args. It does however, ensure args does not have leading + or trailing whitespace. """ - # lex the input into a list of tokens - tokens = self.tokenize(rawinput) + # expand shortcuts and aliases + line = self._expand(rawinput) - # parse out the command and everything else - (command, args) = self._command_and_args(tokens) + command = None + args = None + match = self.command_pattern.search(line) + if match: + # we got a match, extract the command + command = match.group(1) + # the command_pattern regex is designed to match the spaces + # between command and args with a second match group. Using + # the end of the second match group ensures that args has + # no leading whitespace. The rstrip() makes sure there is + # no trailing whitespace + args = line[match.end(2):].rstrip() # build the statement # string representation of args must be an empty string instead of @@ -315,7 +343,6 @@ class StatementParser(): statement.raw = rawinput statement.command = command statement.args = args - statement.argv = tokens return statement def _expand(self, line: str) -> str: diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 7940bbd8..19237f6e 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -44,6 +44,10 @@ def test_tokenize(parser, line, tokens): tokens_to_test = parser.tokenize(line) assert tokens_to_test == tokens +def test_tokenize_unclosed_quotes(parser): + with pytest.raises(ValueError): + tokens = parser.tokenize('command with "unclosed quotes') + @pytest.mark.parametrize('tokens,command,args', [ ([], None, None), (['command'], 'command', None), @@ -59,20 +63,20 @@ def test_command_and_args(parser, tokens, command, args): '"one word"', "'one word'", ]) -def test_single_word(parser, line): +def test_parse_single_word(parser, line): statement = parser.parse(line) assert statement.command == line assert not statement.args assert statement.argv == [utils.strip_quotes(line)] -def test_word_plus_terminator(parser): +def test_parse_word_plus_terminator(parser): line = 'termbare;' statement = parser.parse(line) assert statement.command == 'termbare' assert statement.terminator == ';' assert statement.argv == ['termbare'] -def test_suffix_after_terminator(parser): +def test_parse_suffix_after_terminator(parser): line = 'termbare; suffx' statement = parser.parse(line) assert statement.command == 'termbare' @@ -80,14 +84,14 @@ def test_suffix_after_terminator(parser): assert statement.suffix == 'suffx' assert statement.argv == ['termbare'] -def test_command_with_args(parser): +def test_parse_command_with_args(parser): line = 'command with args' statement = parser.parse(line) assert statement.command == 'command' assert statement.args == 'with args' assert statement.argv == ['command', 'with', 'args'] -def test_command_with_quoted_args(parser): +def test_parse_command_with_quoted_args(parser): line = 'command with "quoted args" and "some not"' statement = parser.parse(line) assert statement.command == 'command' @@ -103,20 +107,20 @@ def test_parse_command_with_args_terminator_and_suffix(parser): assert statement.suffix == 'and suffix' assert statement.argv == ['command', 'with', 'args', 'and', 'terminator'] -def test_hashcomment(parser): +def test_parse_hashcomment(parser): statement = parser.parse('hi # this is all a comment') assert statement.command == 'hi' assert not statement.args assert statement.argv == ['hi'] -def test_c_comment(parser): +def test_parse_c_comment(parser): statement = parser.parse('hi /* this is | all a comment */') assert statement.command == 'hi' assert not statement.args assert not statement.pipe_to assert statement.argv == ['hi'] -def test_c_comment_empty(parser): +def test_parse_c_comment_empty(parser): statement = parser.parse('/* this is | all a comment */') assert not statement.command assert not statement.args @@ -130,14 +134,14 @@ def test_parse_what_if_quoted_strings_seem_to_start_comments(parser): assert not statement.pipe_to assert statement.argv == ['what', 'if', 'quoted strings /* seem to ', 'start', 'comments?'] -def test_simple_piped(parser): +def test_parse_simple_piped(parser): statement = parser.parse('simple | piped') assert statement.command == 'simple' assert not statement.args assert statement.argv == ['simple'] assert statement.pipe_to == 'piped' -def test_double_pipe_is_not_a_pipe(parser): +def test_parse_double_pipe_is_not_a_pipe(parser): line = 'double-pipe || is not a pipe' statement = parser.parse(line) assert statement.command == 'double-pipe' @@ -145,7 +149,7 @@ def test_double_pipe_is_not_a_pipe(parser): assert statement.argv == ['double-pipe', '||', 'is', 'not', 'a', 'pipe'] assert not statement.pipe_to -def test_complex_pipe(parser): +def test_parse_complex_pipe(parser): line = 'command with args, terminator;sufx | piped' statement = parser.parse(line) assert statement.command == 'command' @@ -155,7 +159,7 @@ def test_complex_pipe(parser): assert statement.suffix == 'sufx' assert statement.pipe_to == 'piped' -def test_output_redirect(parser): +def test_parse_output_redirect(parser): line = 'output into > afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -164,7 +168,7 @@ def test_output_redirect(parser): assert statement.output == '>' assert statement.output_to == 'afile.txt' -def test_output_redirect_with_dash_in_path(parser): +def test_parse_output_redirect_with_dash_in_path(parser): line = 'output into > python-cmd2/afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -173,7 +177,7 @@ def test_output_redirect_with_dash_in_path(parser): assert statement.output == '>' assert statement.output_to == 'python-cmd2/afile.txt' -def test_output_redirect_append(parser): +def test_parse_output_redirect_append(parser): line = 'output appended to >> /tmp/afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -182,7 +186,7 @@ def test_output_redirect_append(parser): assert statement.output == '>>' assert statement.output_to == '/tmp/afile.txt' -def test_pipe_and_redirect(parser): +def test_parse_pipe_and_redirect(parser): line = 'output into;sufx | pipethrume plz > afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -202,7 +206,7 @@ def test_parse_output_to_paste_buffer(parser): assert statement.argv == ['output', 'to', 'paste', 'buffer'] assert statement.output == '>>' -def test_has_redirect_inside_terminator(parser): +def test_parse_redirect_inside_terminator(parser): """The terminator designates the end of the commmand/arguments portion. If a redirector occurs before a terminator, then it will be treated as part of the arguments and not as a redirector.""" line = 'has > inside;' @@ -290,6 +294,10 @@ def test_parse_redirect_to_unicode_filename(parser): assert statement.output == '>' assert statement.output_to == 'café' +def test_parse_unclosed_quotes(parser): + with pytest.raises(ValueError): + tokens = parser.tokenize("command with 'unclosed quotes") + def test_empty_statement_raises_exception(): app = cmd2.Cmd() with pytest.raises(cmd2.EmptyStatement): @@ -325,7 +333,6 @@ def test_parse_command_only_command_and_args(parser): statement = parser.parse_command_only(line) assert statement.command == 'help' assert statement.args == 'history' - assert statement.argv == ['help', 'history'] assert statement.command_and_args == line def test_parse_command_only_emptyline(parser): @@ -345,7 +352,6 @@ def test_parse_command_only_strips_line(parser): statement = parser.parse_command_only(line) assert statement.command == 'help' assert statement.args == 'history' - assert statement.argv == ['help', 'history'] assert statement.command_and_args == line.strip() def test_parse_command_only_expands_alias(parser): @@ -353,14 +359,12 @@ def test_parse_command_only_expands_alias(parser): statement = parser.parse_command_only(line) assert statement.command == 'pyscript' assert statement.args == 'foobar.py' - assert statement.argv == ['pyscript', 'foobar.py'] def test_parse_command_only_expands_shortcuts(parser): line = '!cat foobar.txt' statement = parser.parse_command_only(line) assert statement.command == 'shell' assert statement.args == 'cat foobar.txt' - assert statement.argv == ['shell', 'cat', 'foobar.txt'] assert statement.command_and_args == 'shell cat foobar.txt' def test_parse_command_only_quoted_args(parser): -- cgit v1.2.1 From fb4e0c20062d62966962ba0fc09cb46b2ceb3059 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 5 May 2018 21:47:10 -0600 Subject: Refactor parseline() for #380 --- cmd2/cmd2.py | 58 ++++-------------------------------------------------- tests/test_cmd2.py | 18 +++++++++++++++++ 2 files changed, 22 insertions(+), 54 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index db4cef2e..69d2e26e 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1921,66 +1921,16 @@ class Cmd(cmd.Cmd): def parseline(self, line): """Parse the line into a command name and a string containing the arguments. - NOTE: This is an override of a parent class method. It is only used by other parent class methods. But - we do need to override it here so that the additional shortcuts present in cmd2 get properly expanded for - purposes of tab completion. + NOTE: This is an override of a parent class method. It is only used by other parent class methods. - Used for command tab completion. Returns a tuple containing (command, args, line). - 'command' and 'args' may be None if the line couldn't be parsed. + Different from the parent class method, this ignores self.identchars. :param line: str - line read by readline :return: (str, str, str) - tuple containing (command, args, line) """ - line = line.strip() - if not line: - # Deal with empty line or all whitespace line - return None, None, line - - # Make a copy of aliases so we can edit it - tmp_aliases = list(self.aliases.keys()) - keep_expanding = len(tmp_aliases) > 0 - - # Expand aliases - while keep_expanding: - for cur_alias in tmp_aliases: - keep_expanding = False - - if line == cur_alias or line.startswith(cur_alias + ' '): - line = line.replace(cur_alias, self.aliases[cur_alias], 1) - - # Do not expand the same alias more than once - tmp_aliases.remove(cur_alias) - keep_expanding = len(tmp_aliases) > 0 - break - - # Expand command shortcut to its full command name - for (shortcut, expansion) in self.shortcuts: - if line.startswith(shortcut): - # If the next character after the shortcut isn't a space, then insert one - shortcut_len = len(shortcut) - if len(line) == shortcut_len or line[shortcut_len] != ' ': - expansion += ' ' - - # Expand the shortcut - line = line.replace(shortcut, expansion, 1) - break - - i, n = 0, len(line) - - # If we are allowing shell commands, then allow any character in the command - if self.default_to_shell: - while i < n and line[i] != ' ': - i += 1 - - # Otherwise only allow those in identchars - else: - while i < n and line[i] in self.identchars: - i += 1 - - command, arg = line[:i], line[i:].strip() - - return command, arg, line + statement = self.statement_parser.parse_command_only(line) + return statement.command, statement.args, statement.command_and_args def onecmd_plus_hooks(self, line): """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks. diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index b6416005..0da7e9d5 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1718,3 +1718,21 @@ def test_ppaged(base_app): base_app.ppaged(msg) out = base_app.stdout.buffer assert out == msg + end + +# we override cmd.parseline() so we always get consistent +# command parsing by parent methods we don't override +# don't need to test all the parsing logic here, because +# parseline just calls StatementParser.parse_command_only() +def test_parseline_empty(base_app): + statement = '' + command, args, line = base_app.parseline(statement) + assert not command + assert not args + assert not line + +def test_parseline(base_app): + statement = " command with 'partially completed quotes " + command, args, line = base_app.parseline(statement) + assert command == 'command' + assert args == "with 'partially completed quotes" + assert line == statement.strip() -- cgit v1.2.1 From 26d088cc94828879bb1fe43f9713b3b52eb13762 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 5 May 2018 21:59:06 -0600 Subject: Remove check on self.identchars in do_alias() self.identchars is no longer used by cmd2. --- cmd2/cmd2.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ad2038d4..401d2046 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2434,13 +2434,6 @@ Usage: Usage: alias [name] | [ ] name = arglist[0] value = ' '.join(arglist[1:]) - # Check for a valid name - for cur_char in name: - if cur_char not in self.identchars: - self.perror("Alias names can only contain the following characters: {}".format(self.identchars), - traceback_war=False) - return - # Set the alias self.aliases[name] = value self.poutput("Alias {!r} created".format(name)) -- cgit v1.2.1 From 537542b8492f3c4d1c56296804ae82c123d0efce Mon Sep 17 00:00:00 2001 From: kotfu Date: Sat, 5 May 2018 22:04:48 -0600 Subject: Remove unit test for identchars in aliases --- tests/test_cmd2.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index b6416005..33f5d86e 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1688,12 +1688,6 @@ def test_alias_lookup_invalid_alias(base_app, capsys): out, err = capsys.readouterr() assert "not found" in err -def test_alias_with_invalid_name(base_app, capsys): - run_cmd(base_app, 'alias @ help') - out, err = capsys.readouterr() - assert "can only contain the following characters" in err - - def test_unalias(base_app): # Create an alias run_cmd(base_app, 'alias fake pyscript') -- cgit v1.2.1 From 89397b5d0c1e768f6db2b5a7094e1d4541396858 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sun, 6 May 2018 00:26:11 -0400 Subject: Fixed comment --- cmd2/cmd2.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 69d2e26e..1378f052 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1600,8 +1600,7 @@ class Cmd(cmd.Cmd): tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) # Either had a parsing error or are trying to complete the command token - # The latter can happen if default_to_shell is True and parseline() allowed - # assumed something like " or ' was a command. + # The latter can happen if " or ' was entered as the command if tokens is None or len(tokens) == 1: self.completion_matches = [] return None -- cgit v1.2.1 From 2f102b911f1ec87d58039c9a5beca9b23a6c5474 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sun, 6 May 2018 00:30:27 -0400 Subject: Fixed some warnings --- cmd2/parsing.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cmd2/parsing.py b/cmd2/parsing.py index ccea18c9..f2c86ea8 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -81,7 +81,7 @@ class Statement(str): return rtn -class StatementParser(): +class StatementParser: """Parse raw text into command components. Shortcuts is a list of tuples with each tuple containing the shortcut and the expansion. @@ -93,7 +93,7 @@ class StatementParser(): multiline_commands=None, aliases=None, shortcuts=None, - ): + ): self.allow_redirection = allow_redirection if terminators is None: self.terminators = [';'] @@ -151,7 +151,6 @@ class StatementParser(): # multiple lines self.command_pattern = re.compile(r'\A\s*(\S+)(\s|\Z)+') - def tokenize(self, line: str) -> List[str]: """Lex a string into a list of tokens. @@ -369,7 +368,7 @@ class StatementParser(): # expand shortcuts for (shortcut, expansion) in self.shortcuts: - if line.startswith(shortcut): + if line.startswith(shortcut): # If the next character after the shortcut isn't a space, then insert one shortcut_len = len(shortcut) if len(line) == shortcut_len or line[shortcut_len] != ' ': @@ -397,7 +396,7 @@ class StatementParser(): if len(tokens) > 1: args = ' '.join(tokens[1:]) - return (command, args) + return command, args @staticmethod def _comment_replacer(match): @@ -414,7 +413,7 @@ class StatementParser(): # as word breaks when they are in unquoted strings. Each run of punctuation # characters is treated as a single token. - :param initial_tokens: the tokens as parsed by shlex + :param tokens: the tokens as parsed by shlex :return: the punctuated tokens """ punctuation = [] -- cgit v1.2.1 From a0c0db15103a54dba20fb309956a7b3cf90bc645 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 6 May 2018 00:20:46 -0600 Subject: Fix alias expansion when not followed by whitespace --- cmd2/parsing.py | 27 +++++++++++----- tests/test_parsing.py | 85 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 908e9272..eff29843 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -144,13 +144,26 @@ class StatementParser(): # aliases have to be a word, so make a regular expression # that matches the first word in the line. This regex has two # parts, the first parenthesis enclosed group matches one - # or more non-whitespace characters, and the second group - # matches either a whitespace character or the end of the - # string. We use \A and \Z to ensure we always match the - # beginning and end of a string that may have multiple - # lines - self.command_pattern = re.compile(r'\A(\S+)(\s|\Z)') - + # or more non-whitespace characters with a non-greedy match + # (that's what the '+?' part does). The second group must be + # dynamically created because it needs to match either whitespace, + # something in REDIRECTION_CHARS, one of the terminators, + # or the end of the string. We use \A and \Z to ensure we always + # match the beginning and end of a string that may have multiple + # lines (if it's a multiline command) + second_group_items = [] + second_group_items.extend(constants.REDIRECTION_CHARS) + second_group_items.extend(terminators) + # escape each item so it will for sure get treated as a literal + second_group_items = [re.escape(x) for x in second_group_items] + # add the whitespace and end of string, not escaped because they + # are not literals + second_group_items.extend([r'\s', r'\Z']) + # join them up with a pipe + second_group = '|'.join(second_group_items) + # build the regular expression + expr = r'\A(\S+?)({})'.format(second_group) + self.command_pattern = re.compile(expr) def tokenize(self, line: str) -> List[str]: """Lex a string into a list of tokens. diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 7940bbd8..9e48bcf9 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -49,7 +49,7 @@ def test_tokenize(parser, line, tokens): (['command'], 'command', None), (['command', 'arg1', 'arg2'], 'command', 'arg1 arg2') ]) -def test_command_and_args(parser, tokens, command, args): +def test_parse_command_and_args(parser, tokens, command, args): (parsed_command, parsed_args) = parser._command_and_args(tokens) assert command == parsed_command assert args == parsed_args @@ -59,20 +59,20 @@ def test_command_and_args(parser, tokens, command, args): '"one word"', "'one word'", ]) -def test_single_word(parser, line): +def test_parse_single_word(parser, line): statement = parser.parse(line) assert statement.command == line assert not statement.args assert statement.argv == [utils.strip_quotes(line)] -def test_word_plus_terminator(parser): +def test_parse_word_plus_terminator(parser): line = 'termbare;' statement = parser.parse(line) assert statement.command == 'termbare' assert statement.terminator == ';' assert statement.argv == ['termbare'] -def test_suffix_after_terminator(parser): +def test_parse_suffix_after_terminator(parser): line = 'termbare; suffx' statement = parser.parse(line) assert statement.command == 'termbare' @@ -80,14 +80,14 @@ def test_suffix_after_terminator(parser): assert statement.suffix == 'suffx' assert statement.argv == ['termbare'] -def test_command_with_args(parser): +def test_parse_command_with_args(parser): line = 'command with args' statement = parser.parse(line) assert statement.command == 'command' assert statement.args == 'with args' assert statement.argv == ['command', 'with', 'args'] -def test_command_with_quoted_args(parser): +def test_parse_command_with_quoted_args(parser): line = 'command with "quoted args" and "some not"' statement = parser.parse(line) assert statement.command == 'command' @@ -103,20 +103,20 @@ def test_parse_command_with_args_terminator_and_suffix(parser): assert statement.suffix == 'and suffix' assert statement.argv == ['command', 'with', 'args', 'and', 'terminator'] -def test_hashcomment(parser): +def test_parse_hashcomment(parser): statement = parser.parse('hi # this is all a comment') assert statement.command == 'hi' assert not statement.args assert statement.argv == ['hi'] -def test_c_comment(parser): +def test_parse_c_comment(parser): statement = parser.parse('hi /* this is | all a comment */') assert statement.command == 'hi' assert not statement.args assert not statement.pipe_to assert statement.argv == ['hi'] -def test_c_comment_empty(parser): +def test_parse_c_comment_empty(parser): statement = parser.parse('/* this is | all a comment */') assert not statement.command assert not statement.args @@ -130,14 +130,18 @@ def test_parse_what_if_quoted_strings_seem_to_start_comments(parser): assert not statement.pipe_to assert statement.argv == ['what', 'if', 'quoted strings /* seem to ', 'start', 'comments?'] -def test_simple_piped(parser): - statement = parser.parse('simple | piped') +@pytest.mark.parametrize('line',[ + 'simple | piped', + 'simple|piped', +]) +def test_parse_simple_pipe(parser, line): + statement = parser.parse(line) assert statement.command == 'simple' assert not statement.args assert statement.argv == ['simple'] assert statement.pipe_to == 'piped' -def test_double_pipe_is_not_a_pipe(parser): +def test_parse_double_pipe_is_not_a_pipe(parser): line = 'double-pipe || is not a pipe' statement = parser.parse(line) assert statement.command == 'double-pipe' @@ -145,7 +149,7 @@ def test_double_pipe_is_not_a_pipe(parser): assert statement.argv == ['double-pipe', '||', 'is', 'not', 'a', 'pipe'] assert not statement.pipe_to -def test_complex_pipe(parser): +def test_parse_complex_pipe(parser): line = 'command with args, terminator;sufx | piped' statement = parser.parse(line) assert statement.command == 'command' @@ -155,7 +159,20 @@ def test_complex_pipe(parser): assert statement.suffix == 'sufx' assert statement.pipe_to == 'piped' -def test_output_redirect(parser): +@pytest.mark.parametrize('line,output', [ + ('help > out.txt', '>'), + ('help>out.txt', '>'), + ('help >> out.txt', '>>'), + ('help>>out.txt', '>>'), +]) +def test_parse_redirect(parser,line): + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.output == '>' + assert statement.output_to == 'out.txt' + +def test_parse_redirect_with_args(parser): line = 'output into > afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -164,7 +181,7 @@ def test_output_redirect(parser): assert statement.output == '>' assert statement.output_to == 'afile.txt' -def test_output_redirect_with_dash_in_path(parser): +def test_parse_redirect_with_dash_in_path(parser): line = 'output into > python-cmd2/afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -173,7 +190,7 @@ def test_output_redirect_with_dash_in_path(parser): assert statement.output == '>' assert statement.output_to == 'python-cmd2/afile.txt' -def test_output_redirect_append(parser): +def test_parse_redirect_append(parser): line = 'output appended to >> /tmp/afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -182,7 +199,7 @@ def test_output_redirect_append(parser): assert statement.output == '>>' assert statement.output_to == '/tmp/afile.txt' -def test_pipe_and_redirect(parser): +def test_parse_pipe_and_redirect(parser): line = 'output into;sufx | pipethrume plz > afile.txt' statement = parser.parse(line) assert statement.command == 'output' @@ -307,12 +324,12 @@ def test_empty_statement_raises_exception(): ('!ls -al /tmp', 'shell', 'ls -al /tmp'), ('l', 'shell', 'ls -al') ]) -def test_alias_and_shortcut_expansion(parser, line, command, args): +def test_parse_alias_and_shortcut_expansion(parser, line, command, args): statement = parser.parse(line) assert statement.command == command assert statement.args == args -def test_alias_on_multiline_command(parser): +def test_parse_alias_on_multiline_command(parser): line = 'anothermultiline has > inside an unfinished command' statement = parser.parse(line) assert statement.multiline_command == 'multiline' @@ -320,6 +337,36 @@ def test_alias_on_multiline_command(parser): assert statement.args == 'has > inside an unfinished command' assert not statement.terminator +@pytest.mark.parametrize('line,output', [ + ('helpalias > out.txt', '>'), + ('helpalias>out.txt', '>'), + ('helpalias >> out.txt', '>>'), + ('helpalias>>out.txt', '>>'), +]) +def test_parse_alias_redirection(parser, line, output): + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.output == output + assert statement.output_to == 'out.txt' + +@pytest.mark.parametrize('line', [ + 'helpalias | less', + 'helpalias|less', +]) +def test_parse_alias_pipe(parser, line): + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.pipe_to == 'less' + +def test_parse_alias_terminator_no_whitespace(parser): + line = 'helpalias;' + statement = parser.parse(line) + assert statement.command == 'help' + assert not statement.args + assert statement.terminator == ';' + def test_parse_command_only_command_and_args(parser): line = 'help history' statement = parser.parse_command_only(line) -- cgit v1.2.1 From 285b45bfa9ae79a936c35fd9c4b0ea0706082a3d Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 6 May 2018 00:22:41 -0600 Subject: Oops, fixed an oversight that broke the build This is what you get for being too hasty when you push. --- tests/test_parsing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 9e48bcf9..2f3f338f 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -165,11 +165,11 @@ def test_parse_complex_pipe(parser): ('help >> out.txt', '>>'), ('help>>out.txt', '>>'), ]) -def test_parse_redirect(parser,line): +def test_parse_redirect(parser,line, output): statement = parser.parse(line) assert statement.command == 'help' assert not statement.args - assert statement.output == '>' + assert statement.output == output assert statement.output_to == 'out.txt' def test_parse_redirect_with_args(parser): -- cgit v1.2.1 From 529b783234f7721935c0e87a785c094784cb4fff Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 6 May 2018 01:12:14 -0600 Subject: =?UTF-8?q?Don=E2=80=99t=20allow=20wierd=20characters=20in=20alias?= =?UTF-8?q?=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd2/cmd2.py | 19 +++++++++++++++++++ tests/test_cmd2.py | 11 +++++++++++ 2 files changed, 30 insertions(+) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 401d2046..661dd20e 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -730,6 +730,19 @@ class Cmd(cmd.Cmd): # If this string is non-empty, then this warning message will print if a broken pipe error occurs while printing self.broken_pipe_warning = '' + # regular expression to test for invalid characters in aliases + invalid_items = [] + invalid_items.extend(constants.REDIRECTION_CHARS) + invalid_items.extend(self.terminators) + # escape each item so it will for sure get treated as a literal + invalid_items = [re.escape(x) for x in invalid_items] + # don't allow whitespace + invalid_items.append(r'\s') + # join them up with a pipe + expr = '|'.join(invalid_items) + # and compile it into a pattern + self.invalid_alias_pattern = re.compile(expr) + # If a startup script is provided, then add it in the queue to load if startup_script is not None: startup_script = os.path.expanduser(startup_script) @@ -2434,6 +2447,12 @@ Usage: Usage: alias [name] | [ ] name = arglist[0] value = ' '.join(arglist[1:]) + # Validate the alias to ensure it doesn't include wierd characters + # like terminators, output redirection, or whitespace + if self.invalid_alias_pattern.search(name): + self.perror('Alias names can not contain special characters.', traceback_war=False) + return + # Set the alias self.aliases[name] = value self.poutput("Alias {!r} created".format(name)) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 33f5d86e..9dcfe692 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1705,6 +1705,17 @@ def test_unalias_non_existing(base_app, capsys): out, err = capsys.readouterr() assert "does not exist" in err +@pytest.mark.parametrize('alias_name', [ + '">"', + '"no>pe"' + '"no spaces"', + '"nopipe|"', + '"noterm;"', +]) +def test_create_invalid_alias(base_app, alias_name, capsys): + run_cmd(base_app, 'alias {} help'.format(alias_name)) + out, err = capsys.readouterr() + assert "can not contain" in err def test_ppaged(base_app): msg = 'testing...' -- cgit v1.2.1 From 0efb62cfc2b80dabcf0e94ad3315e3ea32c02d4f Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 6 May 2018 10:07:16 -0600 Subject: Fix bungled merge from master --- cmd2/parsing.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/cmd2/parsing.py b/cmd2/parsing.py index a1e21175..d7feeb48 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -142,15 +142,21 @@ class StatementParser: ) # aliases have to be a word, so make a regular expression - # that matches the first word in the line. This regex has two - # parts, the first parenthesis enclosed group matches one - # or more non-whitespace characters with a non-greedy match - # (that's what the '+?' part does). The second group must be - # dynamically created because it needs to match either whitespace, - # something in REDIRECTION_CHARS, one of the terminators, - # or the end of the string. We use \A and \Z to ensure we always - # match the beginning and end of a string that may have multiple - # lines (if it's a multiline command) + # that matches the first word in the line. This regex has three + # parts: + # - the '\A\s*' matches the beginning of the string (even + # if contains multiple lines) and gobbles up any leading + # whitespace + # - the first parenthesis enclosed group matches one + # or more non-whitespace characters with a non-greedy match + # (that's what the '+?' part does). The non-greedy match + # ensures that this first group doesn't include anything + # matched by the second group + # - the second parenthesis group must be dynamically created + # because it needs to match either whitespace, something in + # REDIRECTION_CHARS, one of the terminators, or the end of + # the string (\Z matches the end of the string even if it + # contains multiple lines) second_group_items = [] second_group_items.extend(constants.REDIRECTION_CHARS) second_group_items.extend(terminators) @@ -162,7 +168,7 @@ class StatementParser: # join them up with a pipe second_group = '|'.join(second_group_items) # build the regular expression - expr = r'\A(\S+?)({})'.format(second_group) + expr = r'\A\s*(\S+?)({})'.format(second_group) self.command_pattern = re.compile(expr) def tokenize(self, line: str) -> List[str]: -- cgit v1.2.1 From e945560fe80087bbd0bf91c41e37b17131e83e69 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 6 May 2018 10:07:27 -0600 Subject: Fix pylint warnings --- tests/test_parsing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 1ebadae8..4b972d51 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -46,7 +46,7 @@ def test_tokenize(parser, line, tokens): def test_tokenize_unclosed_quotes(parser): with pytest.raises(ValueError): - tokens = parser.tokenize('command with "unclosed quotes') + _ = parser.tokenize('command with "unclosed quotes') @pytest.mark.parametrize('tokens,command,args', [ ([], None, None), @@ -313,7 +313,7 @@ def test_parse_redirect_to_unicode_filename(parser): def test_parse_unclosed_quotes(parser): with pytest.raises(ValueError): - tokens = parser.tokenize("command with 'unclosed quotes") + _ = parser.tokenize("command with 'unclosed quotes") def test_empty_statement_raises_exception(): app = cmd2.Cmd() -- cgit v1.2.1 From 3343aad02757739fd7ddb91b095db277a59574d9 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 6 May 2018 10:15:42 -0600 Subject: Add more unit tests --- tests/test_parsing.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 4b972d51..f560f993 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -38,7 +38,10 @@ def test_parse_empty_string(parser): ('command /* with some comment */ arg', ['command', 'arg']), ('command arg1 arg2 # comment at the end', ['command', 'arg1', 'arg2']), ('42 arg1 arg2', ['theanswer', 'arg1', 'arg2']), - ('l', ['shell', 'ls', '-al']) + ('l', ['shell', 'ls', '-al']), + ('termbare ; > /tmp/output', ['termbare', ';', '>', '/tmp/output']), + ('help|less', ['help', '|', 'less']), + ('l|less', ['shell', 'ls', '-al', '|', 'less']), ]) def test_tokenize(parser, line, tokens): tokens_to_test = parser.tokenize(line) @@ -69,15 +72,21 @@ def test_parse_single_word(parser, line): assert not statement.args assert statement.argv == [utils.strip_quotes(line)] -def test_parse_word_plus_terminator(parser): - line = 'termbare;' +@pytest.mark.parametrize('line', [ + 'termbare;', + 'termbare ;', +]) +def test_parse_word_plus_terminator(parser, line): statement = parser.parse(line) assert statement.command == 'termbare' assert statement.terminator == ';' assert statement.argv == ['termbare'] -def test_parse_suffix_after_terminator(parser): - line = 'termbare; suffx' +@pytest.mark.parametrize('line', [ + 'termbare; suffx', + 'termbare ;suffx', +]) +def test_parse_suffix_after_terminator(parser, line): statement = parser.parse(line) assert statement.command == 'termbare' assert statement.terminator == ';' -- cgit v1.2.1 From 339094832f9160e1961f4f95c4dc684413ab84c5 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 6 May 2018 10:20:34 -0600 Subject: Clarify comments for self.invalid_alias_pattern --- cmd2/cmd2.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 5c82e953..764f3ce7 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -731,6 +731,8 @@ class Cmd(cmd.Cmd): self.broken_pipe_warning = '' # regular expression to test for invalid characters in aliases + # we will construct it dynamically, because some of the components + # like terminator characters, can change invalid_items = [] invalid_items.extend(constants.REDIRECTION_CHARS) invalid_items.extend(self.terminators) @@ -738,7 +740,8 @@ class Cmd(cmd.Cmd): invalid_items = [re.escape(x) for x in invalid_items] # don't allow whitespace invalid_items.append(r'\s') - # join them up with a pipe + # join them up with a pipe to form a regular expression + # that looks something like r';|>|\||\s' expr = '|'.join(invalid_items) # and compile it into a pattern self.invalid_alias_pattern = re.compile(expr) -- cgit v1.2.1 From 76e7e67e45ca2cac8339a3c3fe56d91d730ad58a Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 6 May 2018 10:22:45 -0600 Subject: Rename unit test --- tests/test_parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index f560f993..43baa0f4 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -56,7 +56,7 @@ def test_tokenize_unclosed_quotes(parser): (['command'], 'command', None), (['command', 'arg1', 'arg2'], 'command', 'arg1 arg2') ]) -def test_parse_command_and_args(parser, tokens, command, args): +def test_command_and_args(parser, tokens, command, args): (parsed_command, parsed_args) = parser._command_and_args(tokens) assert command == parsed_command assert args == parsed_args -- cgit v1.2.1 From d6d92949784b223ad03f70999bc55518c92d9fc9 Mon Sep 17 00:00:00 2001 From: kotfu Date: Sun, 6 May 2018 10:29:55 -0600 Subject: Add unit tests to ensure multiple terminator chars works --- tests/test_parsing.py | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 43baa0f4..ad4d31cd 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -16,7 +16,7 @@ from cmd2 import utils def parser(): parser = StatementParser( allow_redirection=True, - terminators=[';'], + terminators=[';', '&'], multiline_commands=['multiline'], aliases={'helpalias': 'help', '42': 'theanswer', @@ -40,6 +40,9 @@ def test_parse_empty_string(parser): ('42 arg1 arg2', ['theanswer', 'arg1', 'arg2']), ('l', ['shell', 'ls', '-al']), ('termbare ; > /tmp/output', ['termbare', ';', '>', '/tmp/output']), + ('termbare; > /tmp/output', ['termbare', ';', '>', '/tmp/output']), + ('termbare & > /tmp/output', ['termbare', '&', '>', '/tmp/output']), + ('termbare& > /tmp/output', ['termbare', '&', '>', '/tmp/output']), ('help|less', ['help', '|', 'less']), ('l|less', ['shell', 'ls', '-al', '|', 'less']), ]) @@ -72,24 +75,28 @@ def test_parse_single_word(parser, line): assert not statement.args assert statement.argv == [utils.strip_quotes(line)] -@pytest.mark.parametrize('line', [ - 'termbare;', - 'termbare ;', +@pytest.mark.parametrize('line,terminator', [ + ('termbare;', ';'), + ('termbare ;', ';'), + ('termbare&', '&'), + ('termbare &', '&'), ]) -def test_parse_word_plus_terminator(parser, line): +def test_parse_word_plus_terminator(parser, line, terminator): statement = parser.parse(line) assert statement.command == 'termbare' - assert statement.terminator == ';' + assert statement.terminator == terminator assert statement.argv == ['termbare'] -@pytest.mark.parametrize('line', [ - 'termbare; suffx', - 'termbare ;suffx', +@pytest.mark.parametrize('line,terminator', [ + ('termbare; suffx', ';'), + ('termbare ;suffx', ';'), + ('termbare& suffx', '&'), + ('termbare &suffx', '&'), ]) -def test_parse_suffix_after_terminator(parser, line): +def test_parse_suffix_after_terminator(parser, line, terminator): statement = parser.parse(line) assert statement.command == 'termbare' - assert statement.terminator == ';' + assert statement.terminator == terminator assert statement.suffix == 'suffx' assert statement.argv == ['termbare'] @@ -163,12 +170,12 @@ def test_parse_double_pipe_is_not_a_pipe(parser): assert not statement.pipe_to def test_parse_complex_pipe(parser): - line = 'command with args, terminator;sufx | piped' + line = 'command with args, terminator&sufx | piped' statement = parser.parse(line) assert statement.command == 'command' assert statement.args == "with args, terminator" assert statement.argv == ['command', 'with', 'args,', 'terminator'] - assert statement.terminator == ';' + assert statement.terminator == '&' assert statement.suffix == 'sufx' assert statement.pipe_to == 'piped' @@ -251,13 +258,16 @@ def test_parse_unfinished_multiliine_command(parser): assert statement.argv == ['multiline', 'has', '>', 'inside', 'an', 'unfinished', 'command'] assert not statement.terminator -def test_parse_multiline_command_ignores_redirectors_within_it(parser): - line = 'multiline has > inside;' +@pytest.mark.parametrize('line,terminator',[ + ('multiline has > inside;', ';'), + ('multiline has > inside &', '&'), +]) +def test_parse_multiline_command_ignores_redirectors_within_it(parser, line, terminator): statement = parser.parse(line) assert statement.multiline_command == 'multiline' assert statement.args == 'has > inside' assert statement.argv == ['multiline', 'has', '>', 'inside'] - assert statement.terminator == ';' + assert statement.terminator == terminator def test_parse_multiline_with_incomplete_comment(parser): """A terminator within a comment will be ignored and won't terminate a multiline command. -- cgit v1.2.1 From 64f25c5ffcea9e57188e5b38fa52301fa7c21139 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 7 May 2018 14:13:36 -0400 Subject: Added warning if tab completion will be disabled. Not allowing libedit --- cmd2/cmd2.py | 39 +++++++++++++++++++++++++++------------ cmd2/rl_utils.py | 10 ++++++---- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 1378f052..64ad0339 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -27,6 +27,7 @@ import atexit import cmd import codecs import collections +from colorama import Fore import copy import datetime import functools @@ -52,7 +53,14 @@ from . import constants from . import utils # Set up readline -from .rl_utils import rl_force_redisplay, readline, rl_type, RlType +from .rl_utils import rl_type, RlType +if rl_type == RlType.NONE: + rl_err_msg = "Tab completion has been disabled since no supported version of readline was found\n" + rl_err_msg += "To resolve this, install pyreadline on Windows or gnureadline on Mac" + sys.stderr.write(Fore.LIGHTYELLOW_EX + rl_err_msg + Fore.RESET) +else: + from .rl_utils import rl_force_redisplay, readline + from .argparse_completer import AutoCompleter, ACArgumentParser from cmd2.parsing import StatementParser, Statement @@ -321,6 +329,9 @@ class EmptyStatement(Exception): def _pop_readline_history(clear_history: bool=True) -> List[str]: """Returns a copy of readline's history and optionally clears it (default)""" # noinspection PyArgumentList + if rl_type == RlType.NONE: + return [] + history = [ readline.get_history_item(i) for i in range(1, 1 + readline.get_current_history_length()) @@ -332,10 +343,11 @@ def _pop_readline_history(clear_history: bool=True) -> List[str]: def _push_readline_history(history, clear_history=True): """Restores readline's history and optionally clears it first (default)""" - if clear_history: - readline.clear_history() - for line in history: - readline.add_history(line) + if rl_type != RlType.NONE: + if clear_history: + readline.clear_history() + for line in history: + readline.add_history(line) def _complete_from_cmd(cmd_obj, text, line, begidx, endidx): @@ -469,7 +481,7 @@ class AddSubmenu(object): original_attributes = self._get_original_attributes() history = _pop_readline_history() - if self.persistent_history_file: + if self.persistent_history_file and rl_type != RlType.NONE: try: readline.read_history_file(self.persistent_history_file) except FileNotFoundError: @@ -499,7 +511,7 @@ class AddSubmenu(object): self._copy_out_shared_attrs(parent_cmd, original_attributes) # write submenu history - if self.persistent_history_file: + if self.persistent_history_file and rl_type != RlType.NONE: readline.write_history_file(self.persistent_history_file) # reset main app history before exit _push_readline_history(history) @@ -654,7 +666,7 @@ class Cmd(cmd.Cmd): pass # If persistent readline history is enabled, then read history from file and register to write to file at exit - if persistent_history_file: + if persistent_history_file and rl_type != RlType.NONE: persistent_history_file = os.path.expanduser(persistent_history_file) try: readline.read_history_file(persistent_history_file) @@ -1544,7 +1556,7 @@ class Cmd(cmd.Cmd): :param text: str - the current word that user is typing :param state: int - non-negative integer """ - if state == 0: + if state == 0 and rl_type != RlType.NONE: unclosed_quote = '' self.set_completion_defaults() @@ -2616,9 +2628,12 @@ Usage: Usage: unalias [-a] name [name ...] self.poutput(' %2d. %s\n' % (idx + 1, text)) while True: response = input(prompt) - hlen = readline.get_current_history_length() - if hlen >= 1 and response != '': - readline.remove_history_item(hlen - 1) + + if rl_type != RlType.NONE: + hlen = readline.get_current_history_length() + if hlen >= 1 and response != '': + readline.remove_history_item(hlen - 1) + try: response = int(response) result = fulloptions[response - 1][0] diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index 465fcaea..8ef65d28 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -34,11 +34,13 @@ if 'pyreadline' in sys.modules: rl_type = RlType.PYREADLINE elif 'gnureadline' in sys.modules or 'readline' in sys.modules: - rl_type = RlType.GNU + # We don't support libedit + if 'libedit' not in readline.__doc__: + rl_type = RlType.GNU - # Load the readline lib so we can access members of it - import ctypes - readline_lib = ctypes.CDLL(readline.__file__) + # Load the readline lib so we can access members of it + import ctypes + readline_lib = ctypes.CDLL(readline.__file__) def rl_force_redisplay() -> None: -- cgit v1.2.1 From 176c06d9e7521e86228eac4d46953ce15320b8f6 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 7 May 2018 14:17:37 -0400 Subject: Added newline to warning --- cmd2/cmd2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 64ad0339..5c58977b 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -56,7 +56,7 @@ from . import utils from .rl_utils import rl_type, RlType if rl_type == RlType.NONE: rl_err_msg = "Tab completion has been disabled since no supported version of readline was found\n" - rl_err_msg += "To resolve this, install pyreadline on Windows or gnureadline on Mac" + rl_err_msg += "To resolve this, install pyreadline on Windows or gnureadline on Mac\n" sys.stderr.write(Fore.LIGHTYELLOW_EX + rl_err_msg + Fore.RESET) else: from .rl_utils import rl_force_redisplay, readline -- cgit v1.2.1 From f5f0c90aa44ec658b33da422c4f0dc1cea2e6b98 Mon Sep 17 00:00:00 2001 From: kotfu Date: Mon, 7 May 2018 21:01:56 -0600 Subject: Make alias checking and command parsing use the same regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provide a new is_valid_command() method on StatementParser to determine whether a string of characters could be a valid command. That means it can’t include any redirection, quote chars, whitespace, or terminator characters. This method is used when someone tries to create an alias, to ensure when we try and parse the alias that it will actually parse. This nicely encapsulates and standardizes all the logic for parsing and expansion into the StatementParser class. Also fix a bug in the regex to match valid command names, and add a bunch of new unit tests to ensure the bug stays fixed. --- cmd2/cmd2.py | 32 ++++++++------------------ cmd2/parsing.py | 62 ++++++++++++++++++++++++++++++++++++++++++--------- tests/test_cmd2.py | 3 ++- tests/test_parsing.py | 24 ++++++++++++++++++++ 4 files changed, 86 insertions(+), 35 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 764f3ce7..4bec394b 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -730,22 +730,6 @@ class Cmd(cmd.Cmd): # If this string is non-empty, then this warning message will print if a broken pipe error occurs while printing self.broken_pipe_warning = '' - # regular expression to test for invalid characters in aliases - # we will construct it dynamically, because some of the components - # like terminator characters, can change - invalid_items = [] - invalid_items.extend(constants.REDIRECTION_CHARS) - invalid_items.extend(self.terminators) - # escape each item so it will for sure get treated as a literal - invalid_items = [re.escape(x) for x in invalid_items] - # don't allow whitespace - invalid_items.append(r'\s') - # join them up with a pipe to form a regular expression - # that looks something like r';|>|\||\s' - expr = '|'.join(invalid_items) - # and compile it into a pattern - self.invalid_alias_pattern = re.compile(expr) - # If a startup script is provided, then add it in the queue to load if startup_script is not None: startup_script = os.path.expanduser(startup_script) @@ -2396,15 +2380,17 @@ Usage: Usage: alias [name] | [ ] name = arglist[0] value = ' '.join(arglist[1:]) - # Validate the alias to ensure it doesn't include wierd characters + # Validate the alias to ensure it doesn't include weird characters # like terminators, output redirection, or whitespace - if self.invalid_alias_pattern.search(name): - self.perror('Alias names can not contain special characters.', traceback_war=False) - return + valid, invalidchars = self.statement_parser.is_valid_command(name) + if valid: + # Set the alias + self.aliases[name] = value + self.poutput("Alias {!r} created".format(name)) + else: + errmsg = "Aliases can not contain: {}".format(invalidchars) + self.perror(errmsg, traceback_war=False) - # Set the alias - self.aliases[name] = value - self.poutput("Alias {!r} created".format(name)) def complete_alias(self, text, line, begidx, endidx): """ Tab completion for alias """ diff --git a/cmd2/parsing.py b/cmd2/parsing.py index d7feeb48..3a9b390b 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -141,7 +141,7 @@ class StatementParser: re.DOTALL | re.MULTILINE ) - # aliases have to be a word, so make a regular expression + # commands have to be a word, so make a regular expression # that matches the first word in the line. This regex has three # parts: # - the '\A\s*' matches the beginning of the string (even @@ -157,19 +157,51 @@ class StatementParser: # REDIRECTION_CHARS, one of the terminators, or the end of # the string (\Z matches the end of the string even if it # contains multiple lines) - second_group_items = [] - second_group_items.extend(constants.REDIRECTION_CHARS) - second_group_items.extend(terminators) + # + invalid_command_chars = [] + invalid_command_chars.extend(constants.QUOTES) + invalid_command_chars.extend(constants.REDIRECTION_CHARS) + invalid_command_chars.extend(terminators) # escape each item so it will for sure get treated as a literal - second_group_items = [re.escape(x) for x in second_group_items] + second_group_items = [re.escape(x) for x in invalid_command_chars] # add the whitespace and end of string, not escaped because they # are not literals second_group_items.extend([r'\s', r'\Z']) # join them up with a pipe second_group = '|'.join(second_group_items) # build the regular expression - expr = r'\A\s*(\S+?)({})'.format(second_group) - self.command_pattern = re.compile(expr) + expr = r'\A\s*(\S*?)({})'.format(second_group) + self._command_pattern = re.compile(expr) + + def is_valid_command(self, word: str) -> Tuple[bool, str]: + """Determine whether a word is a valid alias. + + Aliases can not include redirection characters, whitespace, + or termination characters. + + If word is not a valid command, return False and a comma + separated string of characters that can not appear in a command. + This string is suitable for inclusion in an error message of your + choice: + + valid, invalidchars = statement_parser.is_valid_command('>') + if not valid: + errmsg = "Aliases can not contain: {}".format(invalidchars) + """ + valid = False + + errmsg = 'whitespace, quotes, ' + errchars = [] + errchars.extend(constants.REDIRECTION_CHARS) + errchars.extend(self.terminators) + errmsg += ', '.join([shlex.quote(x) for x in errchars]) + + match = self._command_pattern.search(word) + if match: + if word == match.group(1): + valid = True + errmsg = None + return valid, errmsg def tokenize(self, line: str) -> List[str]: """Lex a string into a list of tokens. @@ -344,16 +376,24 @@ class StatementParser: command = None args = None - match = self.command_pattern.search(line) + match = self._command_pattern.search(line) if match: # we got a match, extract the command command = match.group(1) - # the command_pattern regex is designed to match the spaces + # the match could be an empty string, if so, turn it into none + if not command: + command = None + # the _command_pattern regex is designed to match the spaces # between command and args with a second match group. Using # the end of the second match group ensures that args has # no leading whitespace. The rstrip() makes sure there is # no trailing whitespace args = line[match.end(2):].rstrip() + # if the command is none that means the input was either empty + # or something wierd like '>'. args should be None if we couldn't + # parse a command + if not command or not args: + args = None # build the statement # string representation of args must be an empty string instead of @@ -375,11 +415,11 @@ class StatementParser: for cur_alias in tmp_aliases: keep_expanding = False # apply our regex to line - match = self.command_pattern.search(line) + match = self._command_pattern.search(line) if match: # we got a match, extract the command command = match.group(1) - if command == cur_alias: + if command and command == cur_alias: # rebuild line with the expanded alias line = self.aliases[cur_alias] + match.group(2) + line[match.end(2):] tmp_aliases.remove(cur_alias) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index c3c9a29f..bc76505f 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1707,10 +1707,11 @@ def test_unalias_non_existing(base_app, capsys): @pytest.mark.parametrize('alias_name', [ '">"', - '"no>pe"' + '"no>pe"', '"no spaces"', '"nopipe|"', '"noterm;"', + 'noembedded"quotes', ]) def test_create_invalid_alias(base_app, alias_name, capsys): run_cmd(base_app, 'alias {} help'.format(alias_name)) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index ad4d31cd..bfb55b23 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -439,3 +439,27 @@ def test_parse_command_only_quoted_args(parser): assert statement.command == 'shell' assert statement.args == 'ls -al "/tmp/directory with spaces/doit.sh"' assert statement.command_and_args == line.replace('l', 'shell ls -al') + +@pytest.mark.parametrize('line', [ + 'helpalias > out.txt', + 'helpalias>out.txt', + 'helpalias >> out.txt', + 'helpalias>>out.txt', + 'help|less', + 'helpalias;', +]) +def test_parse_command_only_specialchars(parser, line): + statement = parser.parse_command_only(line) + assert statement.command == 'help' + +@pytest.mark.parametrize('line', [ + ';', + '>', + "'", + '"', + '|', +]) +def test_parse_command_only_none(parser, line): + statement = parser.parse_command_only(line) + assert statement.command == None + assert statement.args == None -- cgit v1.2.1 From 6470d56fca8b585a1eaec72cee1e9ca8dc1f96ea Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 8 May 2018 09:42:37 -0400 Subject: Grouped readline code together --- cmd2/cmd2.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ee6beb98..8c234dab 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -52,6 +52,8 @@ import pyperclip from . import constants from . import utils +from cmd2.parsing import StatementParser, Statement + # Set up readline from .rl_utils import rl_type, RlType if rl_type == RlType.NONE: @@ -60,25 +62,22 @@ if rl_type == RlType.NONE: sys.stderr.write(Fore.LIGHTYELLOW_EX + rl_err_msg + Fore.RESET) else: from .rl_utils import rl_force_redisplay, readline + from .argparse_completer import AutoCompleter, ACArgumentParser -from .argparse_completer import AutoCompleter, ACArgumentParser - -from cmd2.parsing import StatementParser, Statement - -if rl_type == RlType.PYREADLINE: + if rl_type == RlType.PYREADLINE: - # Save the original pyreadline display completion function since we need to override it and restore it - # noinspection PyProtectedMember - orig_pyreadline_display = readline.rl.mode._display_completions + # Save the original pyreadline display completion function since we need to override it and restore it + # noinspection PyProtectedMember + orig_pyreadline_display = readline.rl.mode._display_completions -elif rl_type == RlType.GNU: + elif rl_type == RlType.GNU: - # We need wcswidth to calculate display width of tab completions - from wcwidth import wcswidth + # We need wcswidth to calculate display width of tab completions + from wcwidth import wcswidth - # Get the readline lib so we can make changes to it - import ctypes - from .rl_utils import readline_lib + # Get the readline lib so we can make changes to it + import ctypes + from .rl_utils import readline_lib # Newer versions of pyperclip are released as a single file, but older versions had a more complicated structure try: -- cgit v1.2.1 From ccc92a283f76b535dff912b10aca4c9782bbde5c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 8 May 2018 09:52:10 -0400 Subject: Expanded readline warning to include other missing features --- cmd2/cmd2.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 8c234dab..24ced18e 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -57,12 +57,12 @@ from cmd2.parsing import StatementParser, Statement # Set up readline from .rl_utils import rl_type, RlType if rl_type == RlType.NONE: - rl_err_msg = "Tab completion has been disabled since no supported version of readline was found\n" - rl_err_msg += "To resolve this, install pyreadline on Windows or gnureadline on Mac\n" + rl_err_msg = "Readline features including tab completion and history have been disabled since no \n" \ + "supported version of readline was found. To resolve this, install pyreadline on \n" \ + "Windows or gnureadline on Mac.\n\n" sys.stderr.write(Fore.LIGHTYELLOW_EX + rl_err_msg + Fore.RESET) else: from .rl_utils import rl_force_redisplay, readline - from .argparse_completer import AutoCompleter, ACArgumentParser if rl_type == RlType.PYREADLINE: @@ -79,6 +79,8 @@ else: import ctypes from .rl_utils import readline_lib +from .argparse_completer import AutoCompleter, ACArgumentParser + # Newer versions of pyperclip are released as a single file, but older versions had a more complicated structure try: from pyperclip.exceptions import PyperclipException -- cgit v1.2.1 From 6a3dbec1111ef1131781a6af441b89f68801c82a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 8 May 2018 10:02:44 -0400 Subject: Updated warning message --- cmd2/cmd2.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 24ced18e..725b6497 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -57,10 +57,10 @@ from cmd2.parsing import StatementParser, Statement # Set up readline from .rl_utils import rl_type, RlType if rl_type == RlType.NONE: - rl_err_msg = "Readline features including tab completion and history have been disabled since no \n" \ - "supported version of readline was found. To resolve this, install pyreadline on \n" \ - "Windows or gnureadline on Mac.\n\n" - sys.stderr.write(Fore.LIGHTYELLOW_EX + rl_err_msg + Fore.RESET) + rl_warning = "Readline features including tab completion have been disabled since no \n" \ + "supported version of readline was found. To resolve this, install \n" \ + "pyreadline on Windows or gnureadline on Mac.\n\n" + sys.stderr.write(Fore.LIGHTYELLOW_EX + rl_warning + Fore.RESET) else: from .rl_utils import rl_force_redisplay, readline -- cgit v1.2.1 From 2cc3d21704c5e9db9c60c6f88262b13ae756e5ce Mon Sep 17 00:00:00 2001 From: kotfu Date: Wed, 9 May 2018 01:02:28 -0400 Subject: Extract submenu code to new project --- cmd2/cmd2.py | 264 ----------------------------------------------- examples/submenus.py | 109 ------------------- tests/test_completion.py | 121 ---------------------- tests/test_submenu.py | 181 -------------------------------- 4 files changed, 675 deletions(-) delete mode 100755 examples/submenus.py delete mode 100644 tests/test_submenu.py diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 725b6497..02ae96fe 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -327,270 +327,6 @@ class EmptyStatement(Exception): pass -def _pop_readline_history(clear_history: bool=True) -> List[str]: - """Returns a copy of readline's history and optionally clears it (default)""" - # noinspection PyArgumentList - if rl_type == RlType.NONE: - return [] - - history = [ - readline.get_history_item(i) - for i in range(1, 1 + readline.get_current_history_length()) - ] - if clear_history: - readline.clear_history() - return history - - -def _push_readline_history(history, clear_history=True): - """Restores readline's history and optionally clears it first (default)""" - if rl_type != RlType.NONE: - if clear_history: - readline.clear_history() - for line in history: - readline.add_history(line) - - -def _complete_from_cmd(cmd_obj, text, line, begidx, endidx): - """Complete as though the user was typing inside cmd's cmdloop()""" - from itertools import takewhile - command_subcommand_params = line.split(None, 3) - - if len(command_subcommand_params) < (3 if text else 2): - n = len(command_subcommand_params[0]) - n += sum(1 for _ in takewhile(str.isspace, line[n:])) - return cmd_obj.completenames(text, line[n:], begidx - n, endidx - n) - - command, subcommand = command_subcommand_params[:2] - n = len(command) + sum(1 for _ in takewhile(str.isspace, line)) - cfun = getattr(cmd_obj, 'complete_' + subcommand, cmd_obj.complete) - return cfun(text, line[n:], begidx - n, endidx - n) - - -class AddSubmenu(object): - """Conveniently add a submenu (Cmd-like class) to a Cmd - - e.g. given "class SubMenu(Cmd): ..." then - - @AddSubmenu(SubMenu(), 'sub') - class MyCmd(cmd.Cmd): - .... - - will have the following effects: - 1. 'sub' will interactively enter the cmdloop of a SubMenu instance - 2. 'sub cmd args' will call do_cmd(args) in a SubMenu instance - 3. 'sub ... [TAB]' will have the same behavior as [TAB] in a SubMenu cmdloop - i.e., autocompletion works the way you think it should - 4. 'help sub [cmd]' will print SubMenu's help (calls its do_help()) - """ - - class _Nonexistent(object): - """ - Used to mark missing attributes. - Disable __dict__ creation since this class does nothing - """ - __slots__ = () # - - def __init__(self, - submenu, - command, - aliases=(), - reformat_prompt="{super_prompt}>> {sub_prompt}", - shared_attributes=None, - require_predefined_shares=True, - create_subclass=False, - preserve_shares=False, - persistent_history_file=None - ): - """Set up the class decorator - - submenu (Cmd): Instance of something cmd.Cmd-like - - command (str): The command the user types to access the SubMenu instance - - aliases (iterable): More commands that will behave like "command" - - reformat_prompt (str): Format str or None to disable - if it's a string, it should contain one or more of: - {super_prompt}: The current cmd's prompt - {command}: The command in the current cmd with which it was called - {sub_prompt}: The subordinate cmd's original prompt - the default is "{super_prompt}{command} {sub_prompt}" - - shared_attributes (dict): dict of the form {'subordinate_attr': 'parent_attr'} - the attributes are copied to the submenu at the last moment; the submenu's - attributes are backed up before this and restored afterward - - require_predefined_shares: The shared attributes above must be independently - defined in the subordinate Cmd (default: True) - - create_subclass: put the modifications in a subclass rather than modifying - the existing class (default: False) - """ - self.submenu = submenu - self.command = command - self.aliases = aliases - if persistent_history_file: - self.persistent_history_file = os.path.expanduser(persistent_history_file) - else: - self.persistent_history_file = None - - if reformat_prompt is not None and not isinstance(reformat_prompt, str): - raise ValueError("reformat_prompt should be either a format string or None") - self.reformat_prompt = reformat_prompt - - self.shared_attributes = {} if shared_attributes is None else shared_attributes - if require_predefined_shares: - for attr in self.shared_attributes.keys(): - if not hasattr(submenu, attr): - raise AttributeError("The shared attribute '{attr}' is not defined in {cmd}. Either define {attr} " - "in {cmd} or set require_predefined_shares=False." - .format(cmd=submenu.__class__.__name__, attr=attr)) - - self.create_subclass = create_subclass - self.preserve_shares = preserve_shares - - def _get_original_attributes(self): - return { - attr: getattr(self.submenu, attr, AddSubmenu._Nonexistent) - for attr in self.shared_attributes.keys() - } - - def _copy_in_shared_attrs(self, parent_cmd): - for sub_attr, par_attr in self.shared_attributes.items(): - setattr(self.submenu, sub_attr, getattr(parent_cmd, par_attr)) - - def _copy_out_shared_attrs(self, parent_cmd, original_attributes): - if self.preserve_shares: - for sub_attr, par_attr in self.shared_attributes.items(): - setattr(parent_cmd, par_attr, getattr(self.submenu, sub_attr)) - else: - for attr, value in original_attributes.items(): - if attr is not AddSubmenu._Nonexistent: - setattr(self.submenu, attr, value) - else: - delattr(self.submenu, attr) - - def __call__(self, cmd_obj): - """Creates a subclass of Cmd wherein the given submenu can be accessed via the given command""" - def enter_submenu(parent_cmd, statement): - """ - This function will be bound to do_ and will change the scope of the CLI to that of the - submenu. - """ - submenu = self.submenu - original_attributes = self._get_original_attributes() - history = _pop_readline_history() - - if self.persistent_history_file and rl_type != RlType.NONE: - try: - readline.read_history_file(self.persistent_history_file) - except FileNotFoundError: - pass - - try: - # copy over any shared attributes - self._copy_in_shared_attrs(parent_cmd) - - if statement.args: - # Remove the menu argument and execute the command in the submenu - submenu.onecmd_plus_hooks(statement.args) - else: - if self.reformat_prompt is not None: - prompt = submenu.prompt - submenu.prompt = self.reformat_prompt.format( - super_prompt=parent_cmd.prompt, - command=self.command, - sub_prompt=prompt, - ) - submenu.cmdloop() - if self.reformat_prompt is not None: - # noinspection PyUnboundLocalVariable - self.submenu.prompt = prompt - finally: - # copy back original attributes - self._copy_out_shared_attrs(parent_cmd, original_attributes) - - # write submenu history - if self.persistent_history_file and rl_type != RlType.NONE: - readline.write_history_file(self.persistent_history_file) - # reset main app history before exit - _push_readline_history(history) - - def complete_submenu(_self, text, line, begidx, endidx): - """ - This function will be bound to complete_ and will perform the complete commands of the submenu. - """ - submenu = self.submenu - original_attributes = self._get_original_attributes() - try: - # copy over any shared attributes - self._copy_in_shared_attrs(_self) - - # Reset the submenu's tab completion parameters - submenu.allow_appended_space = True - submenu.allow_closing_quote = True - submenu.display_matches = [] - - return _complete_from_cmd(submenu, text, line, begidx, endidx) - finally: - # copy back original attributes - self._copy_out_shared_attrs(_self, original_attributes) - - # Pass the submenu's tab completion parameters back up to the menu that called complete() - _self.allow_appended_space = submenu.allow_appended_space - _self.allow_closing_quote = submenu.allow_closing_quote - _self.display_matches = copy.copy(submenu.display_matches) - - original_do_help = cmd_obj.do_help - original_complete_help = cmd_obj.complete_help - - def help_submenu(_self, line): - """ - This function will be bound to help_ and will call the help commands of the submenu. - """ - tokens = line.split(None, 1) - if tokens and (tokens[0] == self.command or tokens[0] in self.aliases): - self.submenu.do_help(tokens[1] if len(tokens) == 2 else '') - else: - original_do_help(_self, line) - - def _complete_submenu_help(_self, text, line, begidx, endidx): - """autocomplete to match help_submenu()'s behavior""" - tokens = line.split(None, 1) - if len(tokens) == 2 and ( - not (not tokens[1].startswith(self.command) and not any( - tokens[1].startswith(alias) for alias in self.aliases)) - ): - return self.submenu.complete_help( - text, - tokens[1], - begidx - line.index(tokens[1]), - endidx - line.index(tokens[1]), - ) - else: - return original_complete_help(_self, text, line, begidx, endidx) - - if self.create_subclass: - class _Cmd(cmd_obj): - do_help = help_submenu - complete_help = _complete_submenu_help - else: - _Cmd = cmd_obj - _Cmd.do_help = help_submenu - _Cmd.complete_help = _complete_submenu_help - - # Create bindings in the parent command to the submenus commands. - setattr(_Cmd, 'do_' + self.command, enter_submenu) - setattr(_Cmd, 'complete_' + self.command, complete_submenu) - - # Create additional bindings for aliases - for _alias in self.aliases: - setattr(_Cmd, 'do_' + _alias, enter_submenu) - setattr(_Cmd, 'complete_' + _alias, complete_submenu) - return _Cmd - - class Cmd(cmd.Cmd): """An easy but powerful framework for writing line-oriented command interpreters. diff --git a/examples/submenus.py b/examples/submenus.py deleted file mode 100755 index 27c8cb10..00000000 --- a/examples/submenus.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 -""" -Create a CLI with a nested command structure as follows. The commands 'second' and 'third' navigate the CLI to the scope -of the submenu. Nesting of the submenus is done with the cmd2.AddSubmenu() decorator. - - (Top Level)----second----->(2nd Level)----third----->(3rd Level) - | | | - ---> say ---> say ---> say -""" -from __future__ import print_function -import sys - -from cmd2 import cmd2 -from IPython import embed - - -class ThirdLevel(cmd2.Cmd): - """To be used as a third level command class. """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.prompt = '3rdLevel ' - self.top_level_attr = None - self.second_level_attr = None - - def do_say(self, line): - print("You called a command in ThirdLevel with '%s'. " - "It has access to top_level_attr: %s " - "and second_level_attr: %s" % (line, self.top_level_attr, self.second_level_attr)) - - def help_say(self): - print("This is a third level submenu (submenu_ab). Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@cmd2.AddSubmenu(ThirdLevel(), - command='third', - aliases=('third_alias',), - shared_attributes=dict(second_level_attr='second_level_attr', top_level_attr='top_level_attr')) -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - self.top_level_attr = None - self.second_level_attr = 987654321 - - def do_ipy(self, arg): - """Enters an interactive IPython shell. - - Run python code from external files with ``run filename.py`` - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - """ - banner = 'Entering an embedded IPython shell type quit() or -d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) - embed(banner1=banner, exit_msg=exit_msg) - - def do_say(self, line): - print("You called a command in SecondLevel with '%s'. " - "It has access to top_level_attr: %s" % (line, self.top_level_attr)) - - def help_say(self): - print("This is a SecondLevel menu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@cmd2.AddSubmenu(SecondLevel(), - command='second', - aliases=('second_alias',), - shared_attributes=dict(top_level_attr='top_level_attr')) -class TopLevel(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - self.top_level_attr = 123456789 - - def do_ipy(self, arg): - """Enters an interactive IPython shell. - - Run python code from external files with ``run filename.py`` - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - """ - banner = 'Entering an embedded IPython shell type quit() or -d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) - embed(banner1=banner, exit_msg=exit_msg) - - def do_say(self, line): - print("You called a command in TopLevel with '%s'. " - "TopLevel has attribute top_level_attr=%s" % (line, self.top_level_attr)) - - def help_say(self): - print("This is a top level submenu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -if __name__ == '__main__': - - root = TopLevel() - root.cmdloop() - diff --git a/tests/test_completion.py b/tests/test_completion.py index bda5bb8a..c7650dbb 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -949,124 +949,3 @@ def test_subcommand_tab_completion_space_in_text_scu(scu_app): assert first_match is not None and \ scu_app.completion_matches == ['Ball" '] and \ scu_app.display_matches == ['Space Ball'] - -#################################################### - - -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - - def do_foo(self, line): - self.poutput("You called a command in SecondLevel with '%s'. " % line) - - def help_foo(self): - self.poutput("This is a second level menu. Options are qwe, asd, zxc") - - def complete_foo(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -second_level_cmd = SecondLevel() - - -@cmd2.AddSubmenu(second_level_cmd, - command='second', - require_predefined_shares=False) -class SubmenuApp(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - - -@pytest.fixture -def sb_app(): - app = SubmenuApp() - return app - - -def test_cmd2_submenu_completion_single_end(sb_app): - text = 'f' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - - # It is at end of line, so extra space is present - assert first_match is not None and sb_app.completion_matches == ['foo '] - - -def test_cmd2_submenu_completion_multiple(sb_app): - text = 'e' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - expected = ['edit', 'eof', 'eos'] - first_match = complete_tester(text, line, begidx, endidx, sb_app) - - assert first_match is not None and sb_app.completion_matches == expected - - -def test_cmd2_submenu_completion_nomatch(sb_app): - text = 'z' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is None - - -def test_cmd2_submenu_completion_after_submenu_match(sb_app): - text = 'a' - line = 'second foo {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is not None and sb_app.completion_matches == ['asd '] - - -def test_cmd2_submenu_completion_after_submenu_nomatch(sb_app): - text = 'b' - line = 'second foo {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is None - - -def test_cmd2_help_submenu_completion_multiple(sb_app): - text = 'p' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - matches = sorted(sb_app.complete_help(text, line, begidx, endidx)) - assert matches == ['py', 'pyscript'] - - -def test_cmd2_help_submenu_completion_nomatch(sb_app): - text = 'fake' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - assert sb_app.complete_help(text, line, begidx, endidx) == [] - - -def test_cmd2_help_submenu_completion_subcommands(sb_app): - text = 'p' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - matches = sorted(sb_app.complete_help(text, line, begidx, endidx)) - assert matches == ['py', 'pyscript'] diff --git a/tests/test_submenu.py b/tests/test_submenu.py deleted file mode 100644 index db334daa..00000000 --- a/tests/test_submenu.py +++ /dev/null @@ -1,181 +0,0 @@ -# coding=utf-8 -""" -Cmd2 testing for argument parsing -""" -import pytest - -from cmd2 import cmd2 -from .conftest import run_cmd, StdOut, normalize - - -class SecondLevelB(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel B ' - - def do_get_top_level_attr(self, line): - self.poutput(str(self.top_level_attr)) - - def do_set_top_level_attr(self, line): - self.top_level_attr = 987654321 - - -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - self.top_level_attr = None - - def do_say(self, line): - self.poutput("You called a command in SecondLevel with '%s'. " % line) - - def help_say(self): - self.poutput("This is a second level menu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - def do_get_top_level_attr(self, line): - self.poutput(str(self.top_level_attr)) - - def do_get_prompt(self, line): - self.poutput(self.prompt) - - -second_level_cmd = SecondLevel() -second_level_b_cmd = SecondLevelB() - - -@cmd2.AddSubmenu(SecondLevelB(), - command='should_work_with_default_kwargs') -@cmd2.AddSubmenu(second_level_b_cmd, - command='secondb', - shared_attributes=dict(top_level_attr='top_level_attr'), - require_predefined_shares=False, - preserve_shares=True - ) -@cmd2.AddSubmenu(second_level_cmd, - command='second', - aliases=('second_alias',), - shared_attributes=dict(top_level_attr='top_level_attr')) -class SubmenuApp(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - self.top_level_attr = 123456789 - - def do_say(self, line): - self.poutput("You called a command in TopLevel with '%s'. " % line) - - def help_say(self): - self.poutput("This is a top level submenu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@pytest.fixture -def submenu_app(): - app = SubmenuApp() - app.stdout = StdOut() - second_level_cmd.stdout = StdOut() - second_level_b_cmd.stdout = StdOut() - return app - - -@pytest.fixture -def secondlevel_app(): - app = SecondLevel() - app.stdout = StdOut() - return app - - -@pytest.fixture -def secondlevel_app_b(): - app = SecondLevelB() - app.stdout = StdOut() - return app - - -def run_submenu_cmd(app, second_level_app, cmd): - """ Clear StdOut buffers, run the command, extract the buffer contents.""" - app.stdout.clear() - second_level_app.stdout.clear() - app.onecmd_plus_hooks(cmd) - out1 = app.stdout.buffer - out2 = second_level_app.stdout.buffer - app.stdout.clear() - second_level_app.stdout.clear() - return normalize(out1), normalize(out2) - - -def test_submenu_say_from_top_level(submenu_app): - line = 'testing' - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'say ' + line) - assert len(out1) == 1 - assert len(out2) == 0 - assert out1[0] == "You called a command in TopLevel with {!r}.".format(line) - - -def test_submenu_second_say_from_top_level(submenu_app): - line = 'testing' - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'second say ' + line) - - # No output expected from the top level - assert out1 == [] - - # Output expected from the second level - assert len(out2) == 1 - assert out2[0] == "You called a command in SecondLevel with {!r}.".format(line) - - -def test_submenu_say_from_second_level(secondlevel_app): - line = 'testing' - out = run_cmd(secondlevel_app, 'say ' + line) - assert out == ["You called a command in SecondLevel with '%s'." % line] - - -def test_submenu_help_second_say_from_top_level(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second say') - # No output expected from the top level - assert out1 == [] - - # Output expected from the second level - assert out2 == ["This is a second level menu. Options are qwe, asd, zxc"] - - -def test_submenu_help_say_from_second_level(secondlevel_app): - out = run_cmd(secondlevel_app, 'help say') - assert out == ["This is a second level menu. Options are qwe, asd, zxc"] - - -def test_submenu_help_second(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second') - out3 = run_cmd(second_level_cmd, 'help') - assert out2 == out3 - - -def test_submenu_from_top_help_second_say(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second say') - out3 = run_cmd(second_level_cmd, 'help say') - assert out2 == out3 - - -def test_submenu_shared_attribute(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'second get_top_level_attr') - assert out2 == [str(submenu_app.top_level_attr)] - - -def test_submenu_shared_attribute_preserve(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb get_top_level_attr') - assert out2 == [str(submenu_app.top_level_attr)] - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb set_top_level_attr') - assert submenu_app.top_level_attr == 987654321 - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb get_top_level_attr') - assert out2 == [str(987654321)] -- cgit v1.2.1 From b88b13ea9a157196bef4269564b0583adc531053 Mon Sep 17 00:00:00 2001 From: kotfu Date: Wed, 9 May 2018 20:10:21 -0400 Subject: Add note regarding submenu --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dac0756f..503f15e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ * Changes * ``strip_ansi()`` and ``strip_quotes()`` functions have moved to new utils module * Several constants moved to new constants module + * Submenu support has been moved to a new [cmd2-submenu](https://github.com/python-cmd2/cmd2-submenu) plugin. If you use submenus, you will need to update your dependencies and modify your imports. * Deletions (potentially breaking changes) * Deleted all ``optparse`` code which had previously been deprecated in release 0.8.0 * The ``options`` decorator no longer exists -- cgit v1.2.1 From ce5092fd9c2e23baa0952aac665e7c26ed85a03a Mon Sep 17 00:00:00 2001 From: kotfu Date: Thu, 10 May 2018 11:11:40 -0600 Subject: Remove cmd2.Cmd.redirector for #381 --- CHANGELOG.md | 1 + cmd2/cmd2.py | 19 ++++++++++++------- cmd2/constants.py | 8 ++++++-- cmd2/parsing.py | 38 +++++++++++++++++++++----------------- docs/freefeatures.rst | 24 +++--------------------- docs/unfreefeatures.rst | 6 +++++- tests/test_cmd2.py | 2 +- tests/test_parsing.py | 17 +++++++++-------- 8 files changed, 58 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 503f15e0..f9627194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ * Deleted ``cmd_with_subs_completer``, ``get_subcommands``, and ``get_subcommand_completer`` * Replaced by default AutoCompleter implementation for all commands using argparse * Deleted support for old method of calling application commands with ``cmd()`` and ``self`` + * ``cmd2.redirector`` is no longer supported. Output redirection can only be done with '>' or '>>' * Python 2 no longer supported * ``cmd2`` now supports Python 3.4+ diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 02ae96fe..eb90c72e 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -338,7 +338,6 @@ class Cmd(cmd.Cmd): # Attributes used to configure the StatementParser, best not to change these at runtime blankLinesAllowed = False multiline_commands = [] - redirector = '>' # for sending output to file shortcuts = {'?': 'help', '!': 'shell', '@': 'load', '@@': '_relative_load'} aliases = dict() terminators = [';'] @@ -1820,7 +1819,7 @@ class Cmd(cmd.Cmd): # We want Popen to raise an exception if it fails to open the process. Thus we don't set shell to True. try: - self.pipe_proc = subprocess.Popen(shlex.split(statement.pipe_to), stdin=subproc_stdin) + self.pipe_proc = subprocess.Popen(statement.pipe_to, stdin=subproc_stdin) except Exception as ex: # Restore stdout to what it was and close the pipe self.stdout.close() @@ -1834,24 +1833,30 @@ class Cmd(cmd.Cmd): raise ex elif statement.output: if (not statement.output_to) and (not can_clip): - raise EnvironmentError('Cannot redirect to paste buffer; install ``xclip`` and re-run to enable') + raise EnvironmentError("Cannot redirect to paste buffer; install 'pyperclip' and re-run to enable") self.kept_state = Statekeeper(self, ('stdout',)) self.kept_sys = Statekeeper(sys, ('stdout',)) self.redirecting = True if statement.output_to: + # going to a file mode = 'w' - if statement.output == 2 * self.redirector: + # statement.output can only contain + # REDIRECTION_APPEND or REDIRECTION_OUTPUT + if statement.output == constants.REDIRECTION_APPEND: mode = 'a' sys.stdout = self.stdout = open(os.path.expanduser(statement.output_to), mode) else: + # going to a paste buffer sys.stdout = self.stdout = tempfile.TemporaryFile(mode="w+") - if statement.output == '>>': + if statement.output == constants.REDIRECTION_APPEND: self.poutput(get_paste_buffer()) def _restore_output(self, statement): - """Handles restoring state after output redirection as well as the actual pipe operation if present. + """Handles restoring state after output redirection as well as + the actual pipe operation if present. - :param statement: Statement object which contains the parsed input from the user + :param statement: Statement object which contains the parsed + input from the user """ # If we have redirected output to a file or the clipboard or piped it to a shell command, then restore state if self.kept_state is not None: diff --git a/cmd2/constants.py b/cmd2/constants.py index 838650e5..af0a44cc 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -4,9 +4,13 @@ import re -# Used for command parsing, tab completion and word breaks. Do not change. +# Used for command parsing, output redirection, tab completion and word +# breaks. Do not change. QUOTES = ['"', "'"] -REDIRECTION_CHARS = ['|', '>'] +REDIRECTION_PIPE = '|' +REDIRECTION_OUTPUT = '>' +REDIRECTION_APPEND = '>>' +REDIRECTION_CHARS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT] # Regular expression to match ANSI escape codes ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 3a9b390b..ce15bd38 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -45,7 +45,8 @@ class Statement(str): redirection, if any :type suffix: str or None :var pipe_to: if output was piped to a shell command, the shell command - :type pipe_to: str or None + as a list of tokens + :type pipe_to: list :var output: if output was redirected, the redirection token, i.e. '>>' :type output: str or None :var output_to: if output was redirected, the destination, usually a filename @@ -283,12 +284,27 @@ class StatementParser: argv = tokens tokens = [] + # check for a pipe to a shell process + # if there is a pipe, everything after the pipe needs to be passed + # to the shell, even redirected output + # this allows '(Cmd) say hello | wc > countit.txt' + try: + # find the first pipe if it exists + pipe_pos = tokens.index(constants.REDIRECTION_PIPE) + # save everything after the first pipe as tokens + pipe_to = tokens[pipe_pos+1:] + # remove all the tokens after the pipe + tokens = tokens[:pipe_pos] + except ValueError: + # no pipe in the tokens + pipe_to = None + # check for output redirect output = None output_to = None try: - output_pos = tokens.index('>') - output = '>' + output_pos = tokens.index(constants.REDIRECTION_OUTPUT) + output = constants.REDIRECTION_OUTPUT output_to = ' '.join(tokens[output_pos+1:]) # remove all the tokens after the output redirect tokens = tokens[:output_pos] @@ -296,26 +312,14 @@ class StatementParser: pass try: - output_pos = tokens.index('>>') - output = '>>' + output_pos = tokens.index(constants.REDIRECTION_APPEND) + output = constants.REDIRECTION_APPEND output_to = ' '.join(tokens[output_pos+1:]) # remove all tokens after the output redirect tokens = tokens[:output_pos] except ValueError: pass - # check for pipes - try: - # find the first pipe if it exists - pipe_pos = tokens.index('|') - # save everything after the first pipe - pipe_to = ' '.join(tokens[pipe_pos+1:]) - # remove all the tokens after the pipe - tokens = tokens[:pipe_pos] - except ValueError: - # no pipe in the tokens - pipe_to = None - if terminator: # whatever is left is the suffix suffix = ' '.join(tokens) diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index 95ae127c..a03a1d08 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -100,26 +100,8 @@ As in a Unix shell, output of a command can be redirected: - appended to a file with ``>>``, as in ``mycommand args >> filename.txt`` - piped (``|``) as input to operating-system commands, as in ``mycommand args | wc`` - - sent to the paste buffer, ready for the next Copy operation, by - ending with a bare ``>``, as in ``mycommand args >``.. Redirecting - to paste buffer requires software to be installed on the operating - system, pywin32_ on Windows or xclip_ on \*nix. + - sent to the operating system paste buffer, by ending with a bare ``>``, as in ``mycommand args >``. You can even append output to the current contents of the paste buffer by ending your command with ``>>``. -If your application depends on mathematical syntax, ``>`` may be a bad -choice for redirecting output - it will prevent you from using the -greater-than sign in your actual user commands. You can override your -app's value of ``self.redirector`` to use a different string for output redirection:: - - class MyApp(cmd2.Cmd): - redirector = '->' - -:: - - (Cmd) say line1 -> out.txt - (Cmd) say line2 ->-> out.txt - (Cmd) !cat out.txt - line1 - line2 .. note:: @@ -136,8 +118,8 @@ app's value of ``self.redirector`` to use a different string for output redirect arguments after them from the command line arguments accordingly. But output from a command will not be redirected to a file or piped to a shell command. -.. _pywin32: http://sourceforge.net/projects/pywin32/ -.. _xclip: http://www.cyberciti.biz/faq/xclip-linux-insert-files-command-output-intoclipboard/ +If you need to include any of these redirection characters in your command, +you can enclose them in quotation marks, ``mycommand 'with > in the argument'``. Python ====== diff --git a/docs/unfreefeatures.rst b/docs/unfreefeatures.rst index a4776a53..41144c8f 100644 --- a/docs/unfreefeatures.rst +++ b/docs/unfreefeatures.rst @@ -10,13 +10,17 @@ commands whose names are listed in the parameter ``app.multiline_commands``. These commands will be executed only after the user has entered a *terminator*. -By default, the command terminators is +By default, the command terminator is ``;``; replacing or appending to the list ``app.terminators`` allows different terminators. A blank line is *always* considered a command terminator (cannot be overridden). +In multiline commands, output redirection characters +like ``>`` and ``|`` are part of the command +arguments unless they appear after the terminator. + Parsed statements ================= diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index bc76505f..6e4a5a3e 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1430,7 +1430,7 @@ def test_clipboard_failure(capsys): # Make sure we got the error output out, err = capsys.readouterr() assert out == '' - assert 'Cannot redirect to paste buffer; install ``xclip`` and re-run to enable' in err + assert "Cannot redirect to paste buffer; install 'pyperclip' and re-run to enable" in err class CmdResultApp(cmd2.Cmd): diff --git a/tests/test_parsing.py b/tests/test_parsing.py index bfb55b23..41966c71 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -159,7 +159,7 @@ def test_parse_simple_pipe(parser, line): assert statement.command == 'simple' assert not statement.args assert statement.argv == ['simple'] - assert statement.pipe_to == 'piped' + assert statement.pipe_to == ['piped'] def test_parse_double_pipe_is_not_a_pipe(parser): line = 'double-pipe || is not a pipe' @@ -177,7 +177,7 @@ def test_parse_complex_pipe(parser): assert statement.argv == ['command', 'with', 'args,', 'terminator'] assert statement.terminator == '&' assert statement.suffix == 'sufx' - assert statement.pipe_to == 'piped' + assert statement.pipe_to == ['piped'] @pytest.mark.parametrize('line,output', [ ('help > out.txt', '>'), @@ -227,9 +227,9 @@ def test_parse_pipe_and_redirect(parser): assert statement.argv == ['output', 'into'] assert statement.terminator == ';' assert statement.suffix == 'sufx' - assert statement.pipe_to == 'pipethrume plz' - assert statement.output == '>' - assert statement.output_to == 'afile.txt' + assert statement.pipe_to == ['pipethrume', 'plz', '>', 'afile.txt'] + assert not statement.output + assert not statement.output_to def test_parse_output_to_paste_buffer(parser): line = 'output to paste buffer >> ' @@ -240,8 +240,9 @@ def test_parse_output_to_paste_buffer(parser): assert statement.output == '>>' def test_parse_redirect_inside_terminator(parser): - """The terminator designates the end of the commmand/arguments portion. If a redirector - occurs before a terminator, then it will be treated as part of the arguments and not as a redirector.""" + """The terminator designates the end of the commmand/arguments portion. + If a redirector occurs before a terminator, then it will be treated as + part of the arguments and not as a redirector.""" line = 'has > inside;' statement = parser.parse(line) assert statement.command == 'has' @@ -385,7 +386,7 @@ def test_parse_alias_pipe(parser, line): statement = parser.parse(line) assert statement.command == 'help' assert not statement.args - assert statement.pipe_to == 'less' + assert statement.pipe_to == ['less'] def test_parse_alias_terminator_no_whitespace(parser): line = 'helpalias;' -- cgit v1.2.1 From ceb3ef56e5c98e3af31de27a2ce43b64324c7e4e Mon Sep 17 00:00:00 2001 From: kotfu Date: Thu, 10 May 2018 11:33:53 -0600 Subject: Get rid of last hard-coded redirection constants --- cmd2/cmd2.py | 11 ++++------- cmd2/constants.py | 1 + 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index eb90c72e..43fd99ec 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1148,29 +1148,26 @@ class Cmd(cmd.Cmd): if len(raw_tokens) > 1: - # Build a list of all redirection tokens - all_redirects = constants.REDIRECTION_CHARS + ['>>'] - # Check if there are redirection strings prior to the token being completed seen_pipe = False has_redirection = False for cur_token in raw_tokens[:-1]: - if cur_token in all_redirects: + if cur_token in constants.REDIRECTION_TOKENS: has_redirection = True - if cur_token == '|': + if cur_token == constants.REDIRECTION_PIPE: seen_pipe = True # Get token prior to the one being completed prior_token = raw_tokens[-2] # If a pipe is right before the token being completed, complete a shell command as the piped process - if prior_token == '|': + if prior_token == constants.REDIRECTION_PIPE: return self.shell_cmd_complete(text, line, begidx, endidx) # Otherwise do path completion either as files to redirectors or arguments to the piped process - elif prior_token in all_redirects or seen_pipe: + elif prior_token in constants.REDIRECTION_TOKENS or seen_pipe: return self.path_complete(text, line, begidx, endidx) # If there were redirection strings anywhere on the command line, then we diff --git a/cmd2/constants.py b/cmd2/constants.py index af0a44cc..b829000f 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -11,6 +11,7 @@ REDIRECTION_PIPE = '|' REDIRECTION_OUTPUT = '>' REDIRECTION_APPEND = '>>' REDIRECTION_CHARS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT] +REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND] # Regular expression to match ANSI escape codes ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') -- cgit v1.2.1 From c76a8ac29f383274e63d3aa52b7346dae208d587 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 15 May 2018 23:12:06 -0400 Subject: Completing alias names in value field of alias command since aliases can be nested --- cmd2/cmd2.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 43fd99ec..9cbbd639 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2142,13 +2142,15 @@ Usage: Usage: alias [name] | [ ] errmsg = "Aliases can not contain: {}".format(invalidchars) self.perror(errmsg, traceback_war=False) - def complete_alias(self, text, line, begidx, endidx): """ Tab completion for alias """ + alias_names = set(self.aliases.keys()) + visible_commands = set(self.get_visible_commands()) + index_dict = \ { - 1: self.aliases, - 2: self.get_visible_commands() + 1: alias_names, + 2: list(alias_names | visible_commands) } return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete) -- cgit v1.2.1