Skip to content

Commit 2528fb5

Browse files
committed
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.
1 parent bf52888 commit 2528fb5

File tree

7 files changed

+136
-24
lines changed

7 files changed

+136
-24
lines changed

cmd2/cmd2.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,7 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, persistent_histor
761761
self.initial_stdout = sys.stdout
762762
self.history = History()
763763
self.pystate = {}
764+
self.pyscript_name = 'app'
764765
self.keywords = self.reserved_words + [fname[3:] for fname in dir(self) if fname.startswith('do_')]
765766
self.parser_manager = ParserManager(redirector=self.redirector, terminators=self.terminators,
766767
multilineCommands=self.multilineCommands,
@@ -2075,6 +2076,8 @@ def onecmd_plus_hooks(self, line):
20752076
if self.allow_redirection:
20762077
self._redirect_output(statement)
20772078
timestart = datetime.datetime.now()
2079+
if self._in_py:
2080+
self._last_result = None
20782081
statement = self.precmd(statement)
20792082
stop = self.onecmd(statement)
20802083
stop = self.postcmd(stop, statement)
@@ -2901,10 +2904,8 @@ def onecmd_plus_hooks(cmd_plus_args):
29012904
return self.onecmd_plus_hooks(cmd_plus_args + '\n')
29022905

29032906
bridge = PyscriptBridge(self)
2904-
self.pystate['self'] = bridge
29052907
self.pystate['run'] = run
2906-
self.pystate['cmd'] = bridge
2907-
self.pystate['app'] = bridge
2908+
self.pystate[self.pyscript_name] = bridge
29082909

29092910
localvars = (self.locals_in_py and self.pystate) or {}
29102911
interp = InteractiveConsole(locals=localvars)

cmd2/pyscript_bridge.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import argparse
1111
from collections import namedtuple
12+
import functools
1213
import sys
1314
from typing import List, Tuple
1415

@@ -19,27 +20,58 @@
1920
from contextlib import redirect_stdout, redirect_stderr
2021

2122
from .argparse_completer import _RangeAction
23+
from .utils import namedtuple_with_defaults
2224

2325

24-
CommandResult = namedtuple('FunctionResult', 'stdout stderr data')
26+
class CommandResult(namedtuple_with_defaults('CmdResult', ['stdout', 'stderr', 'data'])):
27+
"""Encapsulates the results from a command.
28+
29+
Named tuple attributes
30+
----------------------
31+
stdout: str - Output captured from stdout while this command is executing
32+
stderr: str - Output captured from stderr while this command is executing. None if no error captured
33+
data - Data returned by the command.
34+
35+
NOTE: Named tuples are immutable. So the contents are there for access, not for modification.
36+
"""
37+
def __bool__(self):
38+
"""If stderr is None and data is not None the command is considered a success"""
39+
return not self.stderr and self.data is not None
2540

2641

2742
class CopyStream(object):
28-
""" Toy class for replacing self.stdout in cmd2.Cmd instances for unit testing. """
29-
def __init__(self, innerStream):
43+
"""Copies all data written to a stream"""
44+
def __init__(self, inner_stream):
3045
self.buffer = ''
31-
self.innerStream = innerStream
46+
self.inner_stream = inner_stream
3247

3348
def write(self, s):
3449
self.buffer += s
35-
self.innerStream.write(s)
50+
self.inner_stream.write(s)
3651

3752
def read(self):
3853
raise NotImplementedError
3954

4055
def clear(self):
4156
self.buffer = ''
42-
self.innerStream.clear()
57+
58+
59+
def _exec_cmd(cmd2_app, func):
60+
"""Helper to encapsulate executing a command and capturing the results"""
61+
copy_stdout = CopyStream(sys.stdout)
62+
copy_stderr = CopyStream(sys.stderr)
63+
64+
cmd2_app._last_result = None
65+
66+
with redirect_stdout(copy_stdout):
67+
with redirect_stderr(copy_stderr):
68+
func()
69+
70+
# if stderr is empty, set it to None
71+
stderr = copy_stderr if copy_stderr.buffer else None
72+
73+
result = CommandResult(stdout=copy_stdout.buffer, stderr=stderr, data=cmd2_app._last_result)
74+
return result
4375

4476

4577
class ArgparseFunctor:
@@ -208,14 +240,7 @@ def traverse_parser(parser):
208240

209241
# print('Command: {}'.format(cmd_str[0]))
210242

211-
copyStdOut = CopyStream(sys.stdout)
212-
copyStdErr = CopyStream(sys.stderr)
213-
with redirect_stdout(copyStdOut):
214-
with redirect_stderr(copyStdErr):
215-
func(cmd_str[0])
216-
result = CommandResult(stdout=copyStdOut.buffer, stderr=copyStdErr.buffer, data=self._cmd2_app._last_result)
217-
return result
218-
243+
return _exec_cmd(self._cmd2_app, functools.partial(func, cmd_str[0]))
219244

220245
class PyscriptBridge(object):
221246
"""Preserves the legacy 'cmd' interface for pyscript while also providing a new python API wrapper for
@@ -236,8 +261,7 @@ def __getattr__(self, item: str):
236261
except AttributeError:
237262
# Command doesn't, we will accept parameters in the form of a command string
238263
def wrap_func(args=''):
239-
func(args)
240-
return self._cmd2_app._last_result
264+
return _exec_cmd(self._cmd2_app, functools.partial(func, args))
241265
return wrap_func
242266
else:
243267
# Command does use argparse, return an object that can traverse the argparse subcommands and arguments
@@ -246,6 +270,4 @@ def wrap_func(args=''):
246270
raise AttributeError(item)
247271

248272
def __call__(self, args):
249-
self._cmd2_app.onecmd_plus_hooks(args + '\n')
250-
self._last_result = self._cmd2_app._last_result
251-
return self._cmd2_app._last_result
273+
return _exec_cmd(self._cmd2_app, functools.partial(self._cmd2_app.onecmd_plus_hooks, args + '\n'))

cmd2/utils.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# coding=utf-8
33
"""Shared utility functions"""
44

5+
import collections
56
from . import constants
67

78
def strip_ansi(text: str) -> str:
@@ -11,3 +12,32 @@ def strip_ansi(text: str) -> str:
1112
:return: the same string with any ANSI escape codes removed
1213
"""
1314
return constants.ANSI_ESCAPE_RE.sub('', text)
15+
16+
17+
def namedtuple_with_defaults(typename, field_names, default_values=()):
18+
"""
19+
Convenience function for defining a namedtuple with default values
20+
21+
From: https://stackoverflow.com/questions/11351032/namedtuple-and-default-values-for-optional-keyword-arguments
22+
23+
Examples:
24+
>>> Node = namedtuple_with_defaults('Node', 'val left right')
25+
>>> Node()
26+
Node(val=None, left=None, right=None)
27+
>>> Node = namedtuple_with_defaults('Node', 'val left right', [1, 2, 3])
28+
>>> Node()
29+
Node(val=1, left=2, right=3)
30+
>>> Node = namedtuple_with_defaults('Node', 'val left right', {'right':7})
31+
>>> Node()
32+
Node(val=None, left=None, right=7)
33+
>>> Node(4)
34+
Node(val=4, left=None, right=7)
35+
"""
36+
T = collections.namedtuple(typename, field_names)
37+
T.__new__.__defaults__ = (None,) * len(T._fields)
38+
if isinstance(default_values, collections.Mapping):
39+
prototype = T(**default_values)
40+
else:
41+
prototype = T(*default_values)
42+
T.__new__.__defaults__ = tuple(prototype)
43+
return T

examples/tab_autocompletion.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ def do_media(self, args):
325325
# No subcommand was provided, so call help
326326
self.do_help('media')
327327

328+
328329
# This completer is implemented using a single dictionary to look up completion lists for all layers of
329330
# subcommands. For each argument, AutoCompleter will search for completion values from the provided
330331
# arg_choices dict. This requires careful naming of argparse arguments so that there are no unintentional

tests/pyscript/foo4.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
result = app.foo('aaa', 'bbb', counter=3)
2+
out_text = 'Fail'
3+
if result:
4+
data = result.data
5+
if 'aaa' in data.variable and 'bbb' in data.variable and data.counter == 3:
6+
out_text = 'Success'
7+
8+
print(out_text)

tests/scripts/recursive.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
"""
44
Example demonstrating that running a Python script recursively inside another Python script isn't allowed
55
"""
6-
cmd('pyscript ../script.py')
6+
app('pyscript ../script.py')

tests/test_pyscript.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import pytest
99
from cmd2.cmd2 import Cmd, with_argparser
1010
from cmd2 import argparse_completer
11-
from .conftest import run_cmd, normalize, StdOut, complete_tester
11+
from .conftest import run_cmd, StdOut
12+
from cmd2.utils import namedtuple_with_defaults
1213

1314
class PyscriptExample(Cmd):
1415
ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17']
@@ -82,6 +83,16 @@ def do_media(self, args):
8283
@with_argparser(foo_parser)
8384
def do_foo(self, args):
8485
print('foo ' + str(args.__dict__))
86+
if self._in_py:
87+
FooResult = namedtuple_with_defaults('FooResult',
88+
['counter', 'trueval', 'constval',
89+
'variable', 'optional', 'zeroormore'])
90+
self._last_result = FooResult(**{'counter': args.counter,
91+
'trueval': args.trueval,
92+
'constval': args.constval,
93+
'variable': args.variable,
94+
'optional': args.optional,
95+
'zeroormore': args.zeroormore})
8596

8697
bar_parser = argparse_completer.ACArgumentParser(prog='bar')
8798
bar_parser.add_argument('first')
@@ -101,6 +112,23 @@ def ps_app():
101112
return c
102113

103114

115+
class PyscriptCustomNameExample(Cmd):
116+
def __init__(self):
117+
super().__init__()
118+
self.pyscript_name = 'custom'
119+
120+
def do_echo(self, out):
121+
print(out)
122+
123+
124+
@pytest.fixture
125+
def ps_echo():
126+
c = PyscriptCustomNameExample()
127+
c.stdout = StdOut()
128+
129+
return c
130+
131+
104132
@pytest.mark.parametrize('command, pyscript_file', [
105133
('help', 'help.py'),
106134
('help media', 'help_media.py'),
@@ -162,3 +190,25 @@ def test_pyscript_errors(ps_app, capsys, command, error):
162190
assert 'Traceback' in err
163191
assert error in err
164192

193+
194+
@pytest.mark.parametrize('pyscript_file, exp_out', [
195+
('foo4.py', 'Success'),
196+
])
197+
def test_pyscript_results(ps_app, capsys, request, pyscript_file, exp_out):
198+
test_dir = os.path.dirname(request.module.__file__)
199+
python_script = os.path.join(test_dir, 'pyscript', pyscript_file)
200+
201+
run_cmd(ps_app, 'pyscript {}'.format(python_script))
202+
expected, _ = capsys.readouterr()
203+
assert len(expected) > 0
204+
assert exp_out in expected
205+
206+
207+
def test_pyscript_custom_name(ps_echo, capsys):
208+
message = 'blah!'
209+
run_cmd(ps_echo, 'py custom.echo("{}")'.format(message))
210+
expected, _ = capsys.readouterr()
211+
assert len(expected) > 0
212+
expected = expected.splitlines()
213+
assert message == expected[0]
214+

0 commit comments

Comments
 (0)