From 4dc6663b69a97b9f252e594a401de8ad8361194a Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 22 Jun 2024 17:34:20 +0100 Subject: [PATCH] gh-120678: pyrepl: Include globals from modules passed with `-i` --- Lib/_pyrepl/__main__.py | 51 ++----------------- Lib/_pyrepl/_main.py | 48 +++++++++++++++++ Lib/_pyrepl/simple_interact.py | 27 ++++++---- Lib/test/test_pyrepl/test_pyrepl.py | 32 ++++++++++-- ...-06-22-17-01-56.gh-issue-120678.Ik8dCg.rst | 3 ++ 5 files changed, 100 insertions(+), 61 deletions(-) create mode 100644 Lib/_pyrepl/_main.py create mode 100644 Misc/NEWS.d/next/Library/2024-06-22-17-01-56.gh-issue-120678.Ik8dCg.rst diff --git a/Lib/_pyrepl/__main__.py b/Lib/_pyrepl/__main__.py index dae4ba6e178b9a..95e3808fbf7b4d 100644 --- a/Lib/_pyrepl/__main__.py +++ b/Lib/_pyrepl/__main__.py @@ -1,51 +1,8 @@ -import os -import sys +# Important: put as few things as possible in the global namespace of this module, +# as it's easy for things in this module's `__globals__` to accidentally end up +# in the globals of the REPL -CAN_USE_PYREPL: bool -if sys.platform != "win32": - CAN_USE_PYREPL = True -else: - CAN_USE_PYREPL = sys.getwindowsversion().build >= 10586 # Windows 10 TH2 - - -def interactive_console(mainmodule=None, quiet=False, pythonstartup=False): - global CAN_USE_PYREPL - if not CAN_USE_PYREPL: - return sys._baserepl() - - startup_path = os.getenv("PYTHONSTARTUP") - if pythonstartup and startup_path: - import tokenize - with tokenize.open(startup_path) as f: - startup_code = compile(f.read(), startup_path, "exec") - exec(startup_code) - - # set sys.{ps1,ps2} just before invoking the interactive interpreter. This - # mimics what CPython does in pythonrun.c - if not hasattr(sys, "ps1"): - sys.ps1 = ">>> " - if not hasattr(sys, "ps2"): - sys.ps2 = "... " - - run_interactive = None - try: - import errno - if not os.isatty(sys.stdin.fileno()): - raise OSError(errno.ENOTTY, "tty required", "stdin") - from .simple_interact import check - if err := check(): - raise RuntimeError(err) - from .simple_interact import run_multiline_interactive_console - run_interactive = run_multiline_interactive_console - except Exception as e: - from .trace import trace - msg = f"warning: can't use pyrepl: {e}" - trace(msg) - print(msg, file=sys.stderr) - CAN_USE_PYREPL = False - if run_interactive is None: - return sys._baserepl() - return run_interactive(mainmodule) +from ._main import interactive_console if __name__ == "__main__": interactive_console() diff --git a/Lib/_pyrepl/_main.py b/Lib/_pyrepl/_main.py new file mode 100644 index 00000000000000..9e60b8ae7628b5 --- /dev/null +++ b/Lib/_pyrepl/_main.py @@ -0,0 +1,48 @@ +import os +import sys + +CAN_USE_PYREPL: bool +if sys.platform != "win32": + CAN_USE_PYREPL = True +else: + CAN_USE_PYREPL = sys.getwindowsversion().build >= 10586 # Windows 10 TH2 + + +def interactive_console(mainmodule=None, quiet=False, pythonstartup=False): + global CAN_USE_PYREPL + if not CAN_USE_PYREPL: + return sys._baserepl() + + startup_path = os.getenv("PYTHONSTARTUP") + if pythonstartup and startup_path: + import tokenize + with tokenize.open(startup_path) as f: + startup_code = compile(f.read(), startup_path, "exec") + exec(startup_code) + + # set sys.{ps1,ps2} just before invoking the interactive interpreter. This + # mimics what CPython does in pythonrun.c + if not hasattr(sys, "ps1"): + sys.ps1 = ">>> " + if not hasattr(sys, "ps2"): + sys.ps2 = "... " + + run_interactive = None + try: + import errno + if not os.isatty(sys.stdin.fileno()): + raise OSError(errno.ENOTTY, "tty required", "stdin") + from .simple_interact import check + if err := check(): + raise RuntimeError(err) + from .simple_interact import run_multiline_interactive_console + run_interactive = run_multiline_interactive_console + except Exception as e: + from .trace import trace + msg = f"warning: can't use pyrepl: {e}" + trace(msg) + print(msg, file=sys.stderr) + CAN_USE_PYREPL = False + if run_interactive is None: + return sys._baserepl() + return run_interactive(mainmodule) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 2de3b38c37a9da..3f41de7cda82b8 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -80,15 +80,22 @@ def _clear_screen(): "clear": _clear_screen, } -DEFAULT_NAMESPACE: dict[str, Any] = { - '__name__': '__main__', - '__doc__': None, - '__package__': None, - '__loader__': None, - '__spec__': None, - '__annotations__': {}, - '__builtins__': builtins, -} + +def default_namespace() -> dict[str, Any]: + ns = {} + for key, value in sys.modules["__main__"].__dict__.items(): + # avoid `getattr(value, "__module__", "")`, + # as some objects raise `TypeError` on attribute access, etc. + try: + module = value.__module__ + except Exception: + pass + else: + if module.split(".")[0] == "_pyrepl": + continue + ns[key] = value + return ns + def run_multiline_interactive_console( mainmodule: ModuleType | None = None, @@ -96,7 +103,7 @@ def run_multiline_interactive_console( console: code.InteractiveConsole | None = None, ) -> None: from .readline import _setup - namespace = mainmodule.__dict__ if mainmodule else DEFAULT_NAMESPACE + namespace = mainmodule.__dict__ if mainmodule else default_namespace() _setup(namespace) if console is None: diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index adc55f28f08a1e..4aac256891e227 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1,10 +1,12 @@ import io import itertools import os +import pathlib import rlcompleter import select import subprocess import sys +import tempfile from unittest import TestCase, skipUnless from unittest.mock import patch from test.support import force_not_colorized @@ -844,8 +846,8 @@ class TestMain(TestCase): @force_not_colorized def test_exposed_globals_in_repl(self): expected_output = ( - "[\'__annotations__\', \'__builtins__\', \'__doc__\', \'__loader__\', " - "\'__name__\', \'__package__\', \'__spec__\']" + "['__annotations__', '__builtins__', '__cached__', '__doc__', " + "'__file__', '__loader__', '__name__', '__package__', '__spec__']" ) output, exit_code = self.run_repl(["sorted(dir())", "exit"]) if "can\'t use pyrepl" in output: @@ -853,6 +855,19 @@ def test_exposed_globals_in_repl(self): self.assertEqual(exit_code, 0) self.assertIn(expected_output, output) + @force_not_colorized + def test_globals_from_file_passed_included_in_repl_globals(self): + with tempfile.TemporaryDirectory() as td: + fake_main = pathlib.Path(td, "foo.py") + fake_main.write_text("FOO = 42", encoding="utf-8") + output, exit_code = self.run_repl( + ["FOO", "exit"], main_module=str(fake_main) + ) + if "can\'t use pyrepl" in output: + self.skipTest("pyrepl not available") + self.assertEqual(exit_code, 0) + self.assertIn("42", output) + def test_dumb_terminal_exits_cleanly(self): env = os.environ.copy() env.update({"TERM": "dumb"}) @@ -862,10 +877,19 @@ def test_dumb_terminal_exits_cleanly(self): self.assertNotIn("Exception", output) self.assertNotIn("Traceback", output) - def run_repl(self, repl_input: str | list[str], env: dict | None = None) -> tuple[str, int]: + def run_repl( + self, + repl_input: str | list[str], + env: dict | None = None, + *, + main_module: str | None = None + ) -> tuple[str, int]: master_fd, slave_fd = pty.openpty() + repl_args = [sys.executable, "-u", "-i"] + if main_module is not None: + repl_args.append(main_module) process = subprocess.Popen( - [sys.executable, "-i", "-u"], + repl_args, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, diff --git a/Misc/NEWS.d/next/Library/2024-06-22-17-01-56.gh-issue-120678.Ik8dCg.rst b/Misc/NEWS.d/next/Library/2024-06-22-17-01-56.gh-issue-120678.Ik8dCg.rst new file mode 100644 index 00000000000000..ef0d3e3299e2e9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-06-22-17-01-56.gh-issue-120678.Ik8dCg.rst @@ -0,0 +1,3 @@ +Fix regression in the new REPL that meant that globals from files passed +using the ``-i`` argument would not be included in the REPL's global +namespace. Patch by Alex Waygood.