Skip to content

Commit 729e152

Browse files
authored
Merge pull request #888 from python-cmd2/pyscript_fixes
pyscript fixes
2 parents 013b9e0 + 555db5d commit 729e152

File tree

8 files changed

+99
-64
lines changed

8 files changed

+99
-64
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,17 @@
33
* Corrected issue where the actual new value was not always being printed in do_set. This occurred in cases where
44
the typed value differed from what the setter had converted it to.
55
* Fixed bug where ANSI style sequences were not correctly handled in `utils.truncate_line()`.
6+
* Fixed bug where pyscripts could edit `cmd2.Cmd.py_locals` dictionary.
7+
* Fixed bug where cmd2 set sys.path[0] for a pyscript to cmd2's working directory instead of the
8+
script file's directory.
9+
* Fixed bug where sys.path was not being restored after a pyscript ran.
610
* Enhancements
711
* Renamed set command's `-l/--long` flag to `-v/--verbose` for consistency with help and history commands.
12+
* Setting the following pyscript variables:
13+
* `__name__`: __main__
14+
* `__file__`: script path (as typed, ~ will be expanded)
15+
* Other
16+
* Removed undocumented `py run` command since it was replaced by `run_pyscript` a while ago
817

918
## 0.10.0 (February 7, 2020)
1019
* Enhancements

cmd2/cmd2.py

Lines changed: 49 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3093,8 +3093,7 @@ def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None:
30933093

30943094
# This is a hidden flag for telling do_py to run a pyscript. It is intended only to be used by run_pyscript
30953095
# after it sets up sys.argv for the script being run. When this flag is present, it takes precedence over all
3096-
# other arguments. run_pyscript uses this method instead of "py run('file')" because file names with
3097-
# 2 or more consecutive spaces cause issues with our parser, which isn't meant to parse Python statements.
3096+
# other arguments.
30983097
py_parser.add_argument('--pyscript', help=argparse.SUPPRESS)
30993098

31003099
# Preserve quotes since we are passing these strings to Python
@@ -3104,65 +3103,68 @@ def do_py(self, args: argparse.Namespace) -> Optional[bool]:
31043103
Enter an interactive Python shell
31053104
:return: True if running of commands should stop
31063105
"""
3106+
def py_quit():
3107+
"""Function callable from the interactive Python console to exit that environment"""
3108+
raise EmbeddedConsoleExit
3109+
31073110
from .py_bridge import PyBridge
3111+
py_bridge = PyBridge(self)
3112+
saved_sys_path = None
3113+
31083114
if self.in_pyscript():
31093115
err = "Recursively entering interactive Python consoles is not allowed."
31103116
self.perror(err)
31113117
return
31123118

3113-
py_bridge = PyBridge(self)
3114-
py_code_to_run = ''
3115-
3116-
# Handle case where we were called by run_pyscript
3117-
if args.pyscript:
3118-
args.pyscript = utils.strip_quotes(args.pyscript)
3119-
3120-
# Run the script - use repr formatting to escape things which
3121-
# need to be escaped to prevent issues on Windows
3122-
py_code_to_run = 'run({!r})'.format(args.pyscript)
3123-
3124-
elif args.command:
3125-
py_code_to_run = args.command
3126-
if args.remainder:
3127-
py_code_to_run += ' ' + ' '.join(args.remainder)
3128-
3129-
# Set cmd_echo to True so PyBridge statements like: py app('help')
3130-
# run at the command line will print their output.
3131-
py_bridge.cmd_echo = True
3132-
31333119
try:
31343120
self._in_py = True
3121+
py_code_to_run = ''
31353122

3136-
def py_run(filename: str):
3137-
"""Run a Python script file in the interactive console.
3138-
:param filename: filename of script file to run
3139-
"""
3140-
expanded_filename = os.path.expanduser(filename)
3123+
# Use self.py_locals as the locals() dictionary in the Python environment we are creating, but make
3124+
# a copy to prevent pyscripts from editing it. (e.g. locals().clear()). Only make a shallow copy since
3125+
# it's OK for py_locals to contain objects which are editable in a pyscript.
3126+
localvars = dict(self.py_locals)
3127+
localvars[self.py_bridge_name] = py_bridge
3128+
localvars['quit'] = py_quit
3129+
localvars['exit'] = py_quit
3130+
3131+
if self.self_in_py:
3132+
localvars['self'] = self
3133+
3134+
# Handle case where we were called by run_pyscript
3135+
if args.pyscript:
3136+
# Read the script file
3137+
expanded_filename = os.path.expanduser(utils.strip_quotes(args.pyscript))
31413138

31423139
try:
31433140
with open(expanded_filename) as f:
3144-
interp.runcode(f.read())
3141+
py_code_to_run = f.read()
31453142
except OSError as ex:
31463143
self.pexcept("Error reading script file '{}': {}".format(expanded_filename, ex))
3144+
return
31473145

3148-
def py_quit():
3149-
"""Function callable from the interactive Python console to exit that environment"""
3150-
raise EmbeddedConsoleExit
3146+
localvars['__name__'] = '__main__'
3147+
localvars['__file__'] = expanded_filename
31513148

3152-
# Set up Python environment
3153-
self.py_locals[self.py_bridge_name] = py_bridge
3154-
self.py_locals['run'] = py_run
3155-
self.py_locals['quit'] = py_quit
3156-
self.py_locals['exit'] = py_quit
3149+
# Place the script's directory at sys.path[0] just as Python does when executing a script
3150+
saved_sys_path = list(sys.path)
3151+
sys.path.insert(0, os.path.dirname(os.path.abspath(expanded_filename)))
31573152

3158-
if self.self_in_py:
3159-
self.py_locals['self'] = self
3160-
elif 'self' in self.py_locals:
3161-
del self.py_locals['self']
3153+
else:
3154+
# This is the default name chosen by InteractiveConsole when no locals are passed in
3155+
localvars['__name__'] = '__console__'
3156+
3157+
if args.command:
3158+
py_code_to_run = args.command
3159+
if args.remainder:
3160+
py_code_to_run += ' ' + ' '.join(args.remainder)
31623161

3163-
localvars = self.py_locals
3162+
# Set cmd_echo to True so PyBridge statements like: py app('help')
3163+
# run at the command line will print their output.
3164+
py_bridge.cmd_echo = True
3165+
3166+
# Create the Python interpreter
31643167
interp = InteractiveConsole(locals=localvars)
3165-
interp.runcode('import sys, os;sys.path.insert(0, os.getcwd())')
31663168

31673169
# Check if we are running Python code
31683170
if py_code_to_run:
@@ -3177,8 +3179,7 @@ def py_quit():
31773179
else:
31783180
cprt = 'Type "help", "copyright", "credits" or "license" for more information.'
31793181
instructions = ('End with `Ctrl-D` (Unix) / `Ctrl-Z` (Windows), `quit()`, `exit()`.\n'
3180-
'Non-Python commands can be issued with: {}("your command")\n'
3181-
'Run Python code from external script files with: run("script.py")'
3182+
'Non-Python commands can be issued with: {}("your command")'
31823183
.format(self.py_bridge_name))
31833184

31843185
saved_cmd2_env = None
@@ -3205,7 +3206,10 @@ def py_quit():
32053206
pass
32063207

32073208
finally:
3208-
self._in_py = False
3209+
with self.sigint_protection:
3210+
if saved_sys_path is not None:
3211+
sys.path = saved_sys_path
3212+
self._in_py = False
32093213

32103214
return py_bridge.stop
32113215

tests/pyscript/environment.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# flake8: noqa F821
2+
# Tests that cmd2 populates __name__, __file__, and sets sys.path[0] to our directory
3+
import os
4+
import sys
5+
app.cmd_echo = True
6+
7+
if __name__ != '__main__':
8+
print("Error: __name__ is: {}".format(__name__))
9+
quit()
10+
11+
if __file__ != sys.argv[0]:
12+
print("Error: __file__ is: {}".format(__file__))
13+
quit()
14+
15+
our_dir = os.path.dirname(os.path.abspath(__file__))
16+
if our_dir != sys.path[0]:
17+
print("Error: our_dir is: {}".format(our_dir))
18+
quit()
19+
20+
print("PASSED")

tests/pyscript/recursive.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Example demonstrating that calling run_pyscript recursively inside another Python script isn't allowed
66
"""
77
import os
8+
import sys
89

910
app.cmd_echo = True
1011
my_dir = (os.path.dirname(os.path.realpath(sys.argv[0])))

tests/pyscript/run.py

Lines changed: 0 additions & 6 deletions
This file was deleted.

tests/pyscript/to_run.py

Lines changed: 0 additions & 2 deletions
This file was deleted.

tests/test_cmd2.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,24 @@ def test_base_shell(base_app, monkeypatch):
205205

206206

207207
def test_base_py(base_app):
208-
# Create a variable and make sure we can see it
209-
out, err = run_cmd(base_app, 'py qqq=3')
210-
assert not out
208+
# Make sure py can't edit Cmd.py_locals. It used to be that cmd2 was passing its py_locals
209+
# dictionary to the py environment instead of a shallow copy.
210+
base_app.py_locals['test_var'] = 5
211+
out, err = run_cmd(base_app, 'py del[locals()["test_var"]]')
212+
assert not out and not err
213+
assert base_app.py_locals['test_var'] == 5
211214

212-
out, err = run_cmd(base_app, 'py print(qqq)')
213-
assert out[0].rstrip() == '3'
215+
out, err = run_cmd(base_app, 'py print(test_var)')
216+
assert out[0].rstrip() == '5'
217+
218+
# Place an editable object in py_locals. Since we make a shallow copy of py_locals,
219+
# this object should be editable from the py environment.
220+
base_app.py_locals['my_list'] = []
221+
out, err = run_cmd(base_app, 'py my_list.append(2)')
222+
assert not out and not err
223+
assert base_app.py_locals['my_list'][0] == 2
214224

215-
# Add a more complex statement
225+
# Try a print statement
216226
out, err = run_cmd(base_app, 'py print("spaces" + " in this " + "command")')
217227
assert out[0].rstrip() == 'spaces in this command'
218228

tests/test_run_pyscript.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,9 @@ def test_run_pyscript_stop(base_app, request):
117117
stop = base_app.onecmd_plus_hooks('run_pyscript {}'.format(python_script))
118118
assert stop
119119

120-
def test_run_pyscript_run(base_app, request):
120+
def test_run_pyscript_environment(base_app, request):
121121
test_dir = os.path.dirname(request.module.__file__)
122-
python_script = os.path.join(test_dir, 'pyscript', 'run.py')
123-
expected = 'I have been run'
122+
python_script = os.path.join(test_dir, 'pyscript', 'environment.py')
123+
out, err = run_cmd(base_app, 'run_pyscript {}'.format(python_script))
124124

125-
out, err = run_cmd(base_app, "run_pyscript {}".format(python_script))
126-
assert expected in out
125+
assert out[0] == "PASSED"

0 commit comments

Comments
 (0)