diff options
author | Eric Lin <anselor@gmail.com> | 2018-05-02 11:22:10 -0400 |
---|---|---|
committer | Eric Lin <anselor@gmail.com> | 2018-05-02 11:22:10 -0400 |
commit | 2528fb5217063a5a98f7ea2b880bfc75e7f2428c (patch) | |
tree | bfa1d30d5a2e1cebeffac4ea8d867588105b79a9 | |
parent | bf5288829afde976dd213d15aa37704c3eb0a087 (diff) | |
download | cmd2-git-2528fb5217063a5a98f7ea2b880bfc75e7f2428c.tar.gz |
Added support for customizing the pyscript bridge pystate object name.
Removed all legacy pystate objects.
Changed default behavior to clear _last_result before each command
Added utility for creating named tuples with default values
Added tests to exercise new changes.
-rwxr-xr-x | cmd2/cmd2.py | 7 | ||||
-rw-r--r-- | cmd2/pyscript_bridge.py | 60 | ||||
-rw-r--r-- | cmd2/utils.py | 30 | ||||
-rwxr-xr-x | examples/tab_autocompletion.py | 1 | ||||
-rw-r--r-- | tests/pyscript/foo4.py | 8 | ||||
-rw-r--r-- | tests/scripts/recursive.py | 2 | ||||
-rw-r--r-- | tests/test_pyscript.py | 52 |
7 files changed, 136 insertions, 24 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index dc55d4c5..3a07f8ce 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -761,6 +761,7 @@ class Cmd(cmd.Cmd): self.initial_stdout = sys.stdout self.history = History() self.pystate = {} + self.pyscript_name = 'app' self.keywords = self.reserved_words + [fname[3:] for fname in dir(self) if fname.startswith('do_')] self.parser_manager = ParserManager(redirector=self.redirector, terminators=self.terminators, multilineCommands=self.multilineCommands, @@ -2075,6 +2076,8 @@ class Cmd(cmd.Cmd): if self.allow_redirection: self._redirect_output(statement) timestart = datetime.datetime.now() + if self._in_py: + self._last_result = None statement = self.precmd(statement) stop = self.onecmd(statement) stop = self.postcmd(stop, statement) @@ -2901,10 +2904,8 @@ Usage: Usage: unalias [-a] name [name ...] return self.onecmd_plus_hooks(cmd_plus_args + '\n') bridge = PyscriptBridge(self) - self.pystate['self'] = bridge self.pystate['run'] = run - self.pystate['cmd'] = bridge - self.pystate['app'] = bridge + self.pystate[self.pyscript_name] = bridge localvars = (self.locals_in_py and self.pystate) or {} interp = InteractiveConsole(locals=localvars) diff --git a/cmd2/pyscript_bridge.py b/cmd2/pyscript_bridge.py index bf64f50d..055ae4ae 100644 --- a/cmd2/pyscript_bridge.py +++ b/cmd2/pyscript_bridge.py @@ -9,6 +9,7 @@ Released under MIT license, see LICENSE file import argparse from collections import namedtuple +import functools import sys from typing import List, Tuple @@ -19,27 +20,58 @@ else: from contextlib import redirect_stdout, redirect_stderr from .argparse_completer import _RangeAction +from .utils import namedtuple_with_defaults -CommandResult = namedtuple('FunctionResult', 'stdout stderr data') +class CommandResult(namedtuple_with_defaults('CmdResult', ['stdout', 'stderr', 'data'])): + """Encapsulates the results from a command. + + Named tuple attributes + ---------------------- + stdout: str - Output captured from stdout while this command is executing + stderr: str - Output captured from stderr while this command is executing. None if no error captured + data - Data returned by the command. + + NOTE: Named tuples are immutable. So the contents are there for access, not for modification. + """ + def __bool__(self): + """If stderr is None and data is not None the command is considered a success""" + return not self.stderr and self.data is not None class CopyStream(object): - """ Toy class for replacing self.stdout in cmd2.Cmd instances for unit testing. """ - def __init__(self, innerStream): + """Copies all data written to a stream""" + def __init__(self, inner_stream): self.buffer = '' - self.innerStream = innerStream + self.inner_stream = inner_stream def write(self, s): self.buffer += s - self.innerStream.write(s) + self.inner_stream.write(s) def read(self): raise NotImplementedError def clear(self): self.buffer = '' - self.innerStream.clear() + + +def _exec_cmd(cmd2_app, func): + """Helper to encapsulate executing a command and capturing the results""" + copy_stdout = CopyStream(sys.stdout) + copy_stderr = CopyStream(sys.stderr) + + cmd2_app._last_result = None + + with redirect_stdout(copy_stdout): + with redirect_stderr(copy_stderr): + func() + + # if stderr is empty, set it to None + stderr = copy_stderr if copy_stderr.buffer else None + + result = CommandResult(stdout=copy_stdout.buffer, stderr=stderr, data=cmd2_app._last_result) + return result class ArgparseFunctor: @@ -208,14 +240,7 @@ class ArgparseFunctor: # print('Command: {}'.format(cmd_str[0])) - copyStdOut = CopyStream(sys.stdout) - copyStdErr = CopyStream(sys.stderr) - with redirect_stdout(copyStdOut): - with redirect_stderr(copyStdErr): - func(cmd_str[0]) - result = CommandResult(stdout=copyStdOut.buffer, stderr=copyStdErr.buffer, data=self._cmd2_app._last_result) - return result - + return _exec_cmd(self._cmd2_app, functools.partial(func, cmd_str[0])) class PyscriptBridge(object): """Preserves the legacy 'cmd' interface for pyscript while also providing a new python API wrapper for @@ -236,8 +261,7 @@ class PyscriptBridge(object): except AttributeError: # Command doesn't, we will accept parameters in the form of a command string def wrap_func(args=''): - func(args) - return self._cmd2_app._last_result + return _exec_cmd(self._cmd2_app, functools.partial(func, args)) return wrap_func else: # Command does use argparse, return an object that can traverse the argparse subcommands and arguments @@ -246,6 +270,4 @@ class PyscriptBridge(object): raise AttributeError(item) def __call__(self, args): - self._cmd2_app.onecmd_plus_hooks(args + '\n') - self._last_result = self._cmd2_app._last_result - return self._cmd2_app._last_result + return _exec_cmd(self._cmd2_app, functools.partial(self._cmd2_app.onecmd_plus_hooks, args + '\n')) diff --git a/cmd2/utils.py b/cmd2/utils.py index 33215dc0..167879aa 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -2,6 +2,7 @@ # coding=utf-8 """Shared utility functions""" +import collections from . import constants def strip_ansi(text: str) -> str: @@ -11,3 +12,32 @@ def strip_ansi(text: str) -> str: :return: the same string with any ANSI escape codes removed """ return constants.ANSI_ESCAPE_RE.sub('', text) + + +def namedtuple_with_defaults(typename, field_names, default_values=()): + """ + Convenience function for defining a namedtuple with default values + + From: https://stackoverflow.com/questions/11351032/namedtuple-and-default-values-for-optional-keyword-arguments + + Examples: + >>> Node = namedtuple_with_defaults('Node', 'val left right') + >>> Node() + Node(val=None, left=None, right=None) + >>> Node = namedtuple_with_defaults('Node', 'val left right', [1, 2, 3]) + >>> Node() + Node(val=1, left=2, right=3) + >>> Node = namedtuple_with_defaults('Node', 'val left right', {'right':7}) + >>> Node() + Node(val=None, left=None, right=7) + >>> Node(4) + Node(val=4, left=None, right=7) + """ + T = collections.namedtuple(typename, field_names) + T.__new__.__defaults__ = (None,) * len(T._fields) + if isinstance(default_values, collections.Mapping): + prototype = T(**default_values) + else: + prototype = T(*default_values) + T.__new__.__defaults__ = tuple(prototype) + return T diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 38a2b4e9..6146b64b 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -325,6 +325,7 @@ class TabCompleteExample(cmd2.Cmd): # No subcommand was provided, so call help self.do_help('media') + # This completer is implemented using a single dictionary to look up completion lists for all layers of # subcommands. For each argument, AutoCompleter will search for completion values from the provided # arg_choices dict. This requires careful naming of argparse arguments so that there are no unintentional diff --git a/tests/pyscript/foo4.py b/tests/pyscript/foo4.py new file mode 100644 index 00000000..88fd3ce8 --- /dev/null +++ b/tests/pyscript/foo4.py @@ -0,0 +1,8 @@ +result = app.foo('aaa', 'bbb', counter=3) +out_text = 'Fail' +if result: + data = result.data + if 'aaa' in data.variable and 'bbb' in data.variable and data.counter == 3: + out_text = 'Success' + +print(out_text) diff --git a/tests/scripts/recursive.py b/tests/scripts/recursive.py index 84f445bb..32c981b6 100644 --- a/tests/scripts/recursive.py +++ b/tests/scripts/recursive.py @@ -3,4 +3,4 @@ """ Example demonstrating that running a Python script recursively inside another Python script isn't allowed """ -cmd('pyscript ../script.py') +app('pyscript ../script.py') diff --git a/tests/test_pyscript.py b/tests/test_pyscript.py index 5d685c41..8d0cefd8 100644 --- a/tests/test_pyscript.py +++ b/tests/test_pyscript.py @@ -8,7 +8,8 @@ import os import pytest from cmd2.cmd2 import Cmd, with_argparser from cmd2 import argparse_completer -from .conftest import run_cmd, normalize, StdOut, complete_tester +from .conftest import run_cmd, StdOut +from cmd2.utils import namedtuple_with_defaults class PyscriptExample(Cmd): ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17'] @@ -82,6 +83,16 @@ class PyscriptExample(Cmd): @with_argparser(foo_parser) def do_foo(self, args): print('foo ' + str(args.__dict__)) + if self._in_py: + FooResult = namedtuple_with_defaults('FooResult', + ['counter', 'trueval', 'constval', + 'variable', 'optional', 'zeroormore']) + self._last_result = FooResult(**{'counter': args.counter, + 'trueval': args.trueval, + 'constval': args.constval, + 'variable': args.variable, + 'optional': args.optional, + 'zeroormore': args.zeroormore}) bar_parser = argparse_completer.ACArgumentParser(prog='bar') bar_parser.add_argument('first') @@ -101,6 +112,23 @@ def ps_app(): return c +class PyscriptCustomNameExample(Cmd): + def __init__(self): + super().__init__() + self.pyscript_name = 'custom' + + def do_echo(self, out): + print(out) + + +@pytest.fixture +def ps_echo(): + c = PyscriptCustomNameExample() + c.stdout = StdOut() + + return c + + @pytest.mark.parametrize('command, pyscript_file', [ ('help', 'help.py'), ('help media', 'help_media.py'), @@ -162,3 +190,25 @@ def test_pyscript_errors(ps_app, capsys, command, error): assert 'Traceback' in err assert error in err + +@pytest.mark.parametrize('pyscript_file, exp_out', [ + ('foo4.py', 'Success'), +]) +def test_pyscript_results(ps_app, capsys, request, pyscript_file, exp_out): + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, 'pyscript', pyscript_file) + + run_cmd(ps_app, 'pyscript {}'.format(python_script)) + expected, _ = capsys.readouterr() + assert len(expected) > 0 + assert exp_out in expected + + +def test_pyscript_custom_name(ps_echo, capsys): + message = 'blah!' + run_cmd(ps_echo, 'py custom.echo("{}")'.format(message)) + expected, _ = capsys.readouterr() + assert len(expected) > 0 + expected = expected.splitlines() + assert message == expected[0] + |