Unable to run a script in interactive mode `-i`
Issue description and reproduction steps
A pex with no pre-defined entry point or console script will run a script or module (-m) that it is passed as a CLI arg similarly to how the python command line does
$ cat << EOF > foo.py
> bar = 42
> print(f'hello {bar}')
> EOF
$ python foo.py
hello 42
~/code/pantsbuild/pex (main) $ python -m pex -o testpex.pex
~/code/pantsbuild/pex (main) $ ./testpex.pex foo.py
hello 42
the python command line allows you to run the target in interactive mode by passing the -i flag. It executes the target but then hands you interactive control.
$ python -i foo.py
hello 42
>>> bar
42
>>> quit()
Trying something similar with a .pex does not work. The target executes successfully but you get an error stack trace after it. Then you end up in a REPL but it doesn't have the expected names loaded.
~/code/pantsbuild/pex (main) $ ./testpex.pex -i foo.py
hello 42
Traceback (most recent call last):
File "/Users/gauthamnair/.pex/unzipped_pexes/53e3acded6c4b7940375dd07ace6edae8800941b/__main__.py", line 108, in <module>
bootstrap_pex(__entry_point__, execute=__execute__, venv_dir=__venv_dir__)
File "/Users/gauthamnair/.pex/unzipped_pexes/53e3acded6c4b7940375dd07ace6edae8800941b/.bootstrap/pex/pex_bootstrapper.py", line 627, in bootstrap_pex
pex.PEX(entry_point).execute()
File "/Users/gauthamnair/.pex/unzipped_pexes/53e3acded6c4b7940375dd07ace6edae8800941b/.bootstrap/pex/pex.py", line 560, in execute
sys.exit(self._wrap_coverage(self._wrap_profiling, self._execute))
SystemExit
>>> bar
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'bar' is not defined
Motivation
Similar to https://github.com/pantsbuild/pex/issues/2248 this was discovered while trying out support in pants for supplying args to a pants repl command. Running -i some_script.py is one of the more common modifiers to boot an interactive session.
Diagnosis
PEX.execute_interpreter assumes that the user is either trying to run a script/module/etc. or trying to jump into an interactive session. The structure is more or less like this:
def execute_interpreter(self):
args = sys.argv[1:]
python_options = []
for index, arg in enumerate(args):
# ...
# partition python_options from args
# The first time through we are effectively running with a cmdline like:
# python testpex.pex -i foo.py
# => sys.argv[1:] = [-i, foo.py]
# => python_options, args = [-i], [foo.py]
#
# So we reexecute, giving the [-i] to the interpreter
# python -i testpex.pex foo.py
# => sys.argv[1:] = [foo.py]
# => python_options, args = [], [foo.py]
#
# The second time through, this code does not see the interpreter options.
if python_options:
return self.execute_with_options(python_options, args)
if args:
# This is the execute some code branch
arg = args[0]
if arg == "-c":
# ...
return self.execute_content("-c <cmd>", content, argv0="-c")
elif arg == "-m":
# ...
return self.execute_module(module)
else:
content = '...' # grab content from sys.stdin or from the supplied script or directory in arg
sys.argv = args
return self.execute_content(arg, content)
else:
# This is the open-a-REPL branch
# We don't get here because there was an arg [foo.py]
import code
code.interact() # opens an interactive REPL within the current python process
return None
When we come around the second time we execute the script with self.execute_content, which loads its names into a copy of globals() rather than into globals() itself:
# PEX.execute_ast(...)
globals_map = globals().copy()
globals_map["__name__"] = "__main__"
globals_map["__file__"] = name
exec_function(program, globals_map)
return None
and we then then system exit from the calling code in PEX.execute: sys.exit(self._wrap_coverage(self._wrap_profiling, self._execute))
Because the interpreter is in -i mode it stops the system exit and shows us the exit stack trace and drops us in an interpreter, which won't have the loaded names.
It is not very clear how this could be fixed or if there is a much simpler workflow to support https://github.com/pantsbuild/pex/issues/2248 which has no problem running for ipython shells presumably because ipython runs with an entry point default_main = ConsoleScript("ipython")