Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -2630,9 +2630,21 @@ def do_ipy(self, arg):
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 <Ctrl>-d to exit ...'
exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])
embed(banner1=banner, exit_msg=exit_msg)
from .pyscript_bridge import PyscriptBridge
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for making the features added by pyscript_bridge available consistently using either the py or ipy commands.

bridge = PyscriptBridge(self)

if self.locals_in_py:
def load_ipy(self, app):
banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...'
exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])
embed(banner1=banner, exit_msg=exit_msg)
load_ipy(self, bridge)
else:
def load_ipy(app):
banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...'
exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])
embed(banner1=banner, exit_msg=exit_msg)
load_ipy(bridge)

history_parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
history_parser_group = history_parser.add_mutually_exclusive_group()
Expand Down
76 changes: 52 additions & 24 deletions cmd2/pyscript_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
"""

import argparse
from collections import namedtuple
import functools
import sys
from typing import List, Tuple
from typing import List, Tuple, Callable

# Python 3.4 require contextlib2 for temporarily redirecting stderr and stdout
if sys.version_info < (3, 5):
Expand Down Expand Up @@ -41,54 +40,78 @@ def __bool__(self):

class CopyStream(object):
"""Copies all data written to a stream"""
def __init__(self, inner_stream):
def __init__(self, inner_stream, echo: bool = False):
self.buffer = ''
self.inner_stream = inner_stream
self.echo = echo

def write(self, s):
self.buffer += s
self.inner_stream.write(s)
if self.echo:
self.inner_stream.write(s)

def read(self):
raise NotImplementedError

def clear(self):
self.buffer = ''

def __getattr__(self, item: str):
if item in self.__dict__:
return self.__dict__[item]
else:
return getattr(self.inner_stream, item)


def _exec_cmd(cmd2_app, func):
def _exec_cmd(cmd2_app, func: Callable, echo: bool):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this echo also default to False?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is intended to be an internal function I think I'd prefer the caller to be explicit about it.

"""Helper to encapsulate executing a command and capturing the results"""
copy_stdout = CopyStream(sys.stdout)
copy_stderr = CopyStream(sys.stderr)
copy_stdout = CopyStream(sys.stdout, echo)
copy_stderr = CopyStream(sys.stderr, echo)

copy_cmd_stdout = CopyStream(cmd2_app.stdout, echo)

cmd2_app._last_result = None

with redirect_stdout(copy_stdout):
with redirect_stderr(copy_stderr):
func()
try:
cmd2_app.stdout = copy_cmd_stdout
with redirect_stdout(copy_stdout):
with redirect_stderr(copy_stderr):
func()
finally:
cmd2_app.stdout = copy_cmd_stdout.inner_stream

# if stderr is empty, set it to None
stderr = copy_stderr if copy_stderr.buffer else None
stderr = copy_stderr.buffer if copy_stderr.buffer else None

result = CommandResult(stdout=copy_stdout.buffer, stderr=stderr, data=cmd2_app._last_result)
outbuf = copy_cmd_stdout.buffer if copy_cmd_stdout.buffer else copy_stdout.buffer
result = CommandResult(stdout=outbuf, stderr=stderr, data=cmd2_app._last_result)
return result


class ArgparseFunctor:
"""
Encapsulates translating python object traversal
"""
def __init__(self, cmd2_app, item, parser):
def __init__(self, echo: bool, cmd2_app, command_name: str, parser: argparse.ArgumentParser):
self._echo = echo
self._cmd2_app = cmd2_app
self._item = item
self._command_name = command_name
self._parser = parser

# Dictionary mapping command argument name to value
self._args = {}
# argparse object for the current command layer
self.__current_subcommand_parser = parser

def __getattr__(self, item):
def __dir__(self):
"""Returns a custom list of attribute names to match the sub-commands"""
commands = []
for action in self.__current_subcommand_parser._actions:
if not action.option_strings and isinstance(action, argparse._SubParsersAction):
commands.extend(action.choices)
return commands

def __getattr__(self, item: str):
"""Search for a subcommand matching this item and update internal state to track the traversal"""
# look for sub-command under the current command/sub-command layer
for action in self.__current_subcommand_parser._actions:
Expand All @@ -101,7 +124,6 @@ def __getattr__(self, item):
return self

raise AttributeError(item)
# return super().__getattr__(item)

def __call__(self, *args, **kwargs):
"""
Expand Down Expand Up @@ -182,7 +204,7 @@ def __call__(self, *args, **kwargs):

def _run(self):
# look up command function
func = getattr(self._cmd2_app, 'do_' + self._item)
func = getattr(self._cmd2_app, 'do_' + self._command_name)

# reconstruct the cmd2 command from the python call
cmd_str = ['']
Expand Down Expand Up @@ -238,16 +260,16 @@ def traverse_parser(parser):

traverse_parser(self._parser)

# print('Command: {}'.format(cmd_str[0]))
return _exec_cmd(self._cmd2_app, functools.partial(func, cmd_str[0]), self._echo)

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
application commands."""
def __init__(self, cmd2_app):
self._cmd2_app = cmd2_app
self._last_result = None
self.cmd_echo = False

def __getattr__(self, item: str):
"""Check if the attribute is a command. If so, return a callable."""
Expand All @@ -261,13 +283,19 @@ def __getattr__(self, item: str):
except AttributeError:
# Command doesn't, we will accept parameters in the form of a command string
def wrap_func(args=''):
return _exec_cmd(self._cmd2_app, functools.partial(func, args))
return _exec_cmd(self._cmd2_app, functools.partial(func, args), self.cmd_echo)
return wrap_func
else:
# Command does use argparse, return an object that can traverse the argparse subcommands and arguments
return ArgparseFunctor(self._cmd2_app, item, parser)
return ArgparseFunctor(self.cmd_echo, self._cmd2_app, item, parser)

raise AttributeError(item)
return super().__getattr__(item)

def __dir__(self):
"""Return a custom set of attribute names to match the available commands"""
commands = list(self._cmd2_app.get_all_commands())
commands.insert(0, 'cmd_echo')
return commands

def __call__(self, args):
return _exec_cmd(self._cmd2_app, functools.partial(self._cmd2_app.onecmd_plus_hooks, args + '\n'))
def __call__(self, args: str):
return _exec_cmd(self._cmd2_app, functools.partial(self._cmd2_app.onecmd_plus_hooks, args + '\n'), self.cmd_echo)
1 change: 1 addition & 0 deletions tests/pyscript/bar1.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.bar('11', '22')
2 changes: 2 additions & 0 deletions tests/pyscript/custom_echo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
custom.cmd_echo = True
custom.echo('blah!')
1 change: 1 addition & 0 deletions tests/pyscript/foo1.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.foo('aaa', 'bbb', counter=3, trueval=True, constval=True)
1 change: 1 addition & 0 deletions tests/pyscript/foo2.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.foo('11', '22', '33', '44', counter=3, trueval=True, constval=True)
1 change: 1 addition & 0 deletions tests/pyscript/foo3.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.foo('11', '22', '33', '44', '55', '66', counter=3, trueval=False, constval=False)
1 change: 1 addition & 0 deletions tests/pyscript/foo4.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
app.cmd_echo = True
result = app.foo('aaa', 'bbb', counter=3)
out_text = 'Fail'
if result:
Expand Down
3 changes: 2 additions & 1 deletion tests/pyscript/help.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.help()
app.cmd_echo = True
app.help()
1 change: 1 addition & 0 deletions tests/pyscript/help_media.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.help('media')
1 change: 1 addition & 0 deletions tests/pyscript/media_movies_add1.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.media.movies.add('My Movie', 'PG-13', director=('George Lucas', 'J. J. Abrams'))
1 change: 1 addition & 0 deletions tests/pyscript/media_movies_add2.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.media.movies.add('My Movie', 'PG-13', actor=('Mark Hamill'), director=('George Lucas', 'J. J. Abrams'))
3 changes: 2 additions & 1 deletion tests/pyscript/media_movies_list1.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.media.movies.list()
app.cmd_echo = True
app.media.movies.list()
3 changes: 2 additions & 1 deletion tests/pyscript/media_movies_list2.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.media().movies().list()
app.cmd_echo = True
app.media().movies().list()
3 changes: 2 additions & 1 deletion tests/pyscript/media_movies_list3.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app('media movies list')
app.cmd_echo = True
app('media movies list')
1 change: 1 addition & 0 deletions tests/pyscript/media_movies_list4.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.media.movies.list(actor='Mark Hamill')
1 change: 1 addition & 0 deletions tests/pyscript/media_movies_list5.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.media.movies.list(actor=('Mark Hamill', 'Carrie Fisher'))
1 change: 1 addition & 0 deletions tests/pyscript/media_movies_list6.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.media.movies.list(rating='PG')
1 change: 1 addition & 0 deletions tests/pyscript/media_movies_list7.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
app.cmd_echo = True
app.media.movies.list(rating=('PG', 'PG-13'))
3 changes: 3 additions & 0 deletions tests/pyscript/pyscript_dir1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
out = dir(app)
out.sort()
print(out)
3 changes: 3 additions & 0 deletions tests/pyscript/pyscript_dir2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
out = dir(app.media)
out.sort()
print(out)
1 change: 1 addition & 0 deletions tests/scripts/recursive.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
"""
Example demonstrating that running a Python script recursively inside another Python script isn't allowed
"""
app.cmd_echo = True
app('pyscript ../script.py')
36 changes: 31 additions & 5 deletions tests/test_pyscript.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,14 @@ def do_foo(self, args):

@with_argparser(bar_parser)
def do_bar(self, args):
print('bar ' + str(args.__dict__))
out = 'bar '
arg_dict = args.__dict__
keys = list(arg_dict.keys())
keys.sort()
out += '{'
for key in keys:
out += "'{}':'{}'".format(key, arg_dict[key])
print(out)


@pytest.fixture
Expand Down Expand Up @@ -160,7 +167,7 @@ def test_pyscript_help(ps_app, capsys, request, command, pyscript_file):
('foo aaa bbb -ccc -t -n', 'foo1.py'),
('foo 11 22 33 44 -ccc -t -n', 'foo2.py'),
('foo 11 22 33 44 55 66 -ccc', 'foo3.py'),
('bar 11 22', 'bar1.py')
('bar 11 22', 'bar1.py'),
])
def test_pyscript_out(ps_app, capsys, request, command, pyscript_file):
test_dir = os.path.dirname(request.module.__file__)
Expand Down Expand Up @@ -204,11 +211,30 @@ def test_pyscript_results(ps_app, capsys, request, pyscript_file, exp_out):
assert exp_out in expected


def test_pyscript_custom_name(ps_echo, capsys):
@pytest.mark.parametrize('expected, pyscript_file', [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding some unit tests

("['_relative_load', 'alias', 'bar', 'cmd_echo', 'edit', 'eof', 'eos', 'foo', 'help', 'history', 'load', 'media', 'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts', 'unalias']",
'pyscript_dir1.py'),
("['movies', 'shows']", 'pyscript_dir2.py')
])
def test_pyscript_dir(ps_app, capsys, request, expected, pyscript_file):
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))
out, _ = capsys.readouterr()
out = out.strip()
assert len(out) > 0
assert out == expected


def test_pyscript_custom_name(ps_echo, capsys, request):
message = 'blah!'
run_cmd(ps_echo, 'py custom.echo("{}")'.format(message))

test_dir = os.path.dirname(request.module.__file__)
python_script = os.path.join(test_dir, 'pyscript', 'custom_echo.py')

run_cmd(ps_echo, 'pyscript {}'.format(python_script))
expected, _ = capsys.readouterr()
assert len(expected) > 0
expected = expected.splitlines()
assert message == expected[0]