summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xcmd2/cmd2.py7
-rw-r--r--cmd2/pyscript_bridge.py60
-rw-r--r--cmd2/utils.py30
-rwxr-xr-xexamples/tab_autocompletion.py1
-rw-r--r--tests/pyscript/foo4.py8
-rw-r--r--tests/scripts/recursive.py2
-rw-r--r--tests/test_pyscript.py52
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]
+