diff --git a/python_files/pythonrc.py b/python_files/pythonrc.py index 3042ffb7a309..e248ebe49421 100644 --- a/python_files/pythonrc.py +++ b/python_files/pythonrc.py @@ -7,6 +7,21 @@ original_ps1 = ">>> " is_wsl = "microsoft-standard-WSL" in platform.release() +# NOTE: PYTHONSTARTUP executes this file's code directly inside the user's +# __main__ namespace, so everything defined below actually lives in +# __main__.__dict__. That means PS1.__str__.__globals__ IS the user's +# namespace: if the user later shadows a name we rely on at prompt-render +# time (e.g. `int = 20`, `sys = 1`, `original_ps1 = ...`), a plain global +# lookup would resolve to the user's value instead of ours and raise, +# silently killing the prompt (see #26039). Capture the real objects here, +# before any user code runs, so later reassignment in __main__ can't affect +# us. +_int = int +_str = str +_bool = bool +_sys = sys +_original_ps1 = original_ps1 + class REPLHooks: def __init__(self): @@ -33,12 +48,18 @@ def my_excepthook(self, type_, value, traceback): def get_last_command(): # Get the last history item last_command = "" - if sys.platform != "win32": - last_command = readline.get_history_item(readline.get_current_history_length()) + if _sys.platform != "win32": + last_command = _readline.get_history_item(_readline.get_current_history_length()) return last_command +# Capture the function object itself, not just the name: `get_last_command` +# is also just a name in __main__, so it could be shadowed the same way. +_get_last_command = get_last_command +_readline = readline if sys.platform != "win32" else None + + class PS1: hooks = REPLHooks() sys.excepthook = hooks.my_excepthook @@ -46,27 +67,27 @@ class PS1: # str will get called for every prompt with exit code to show success/failure def __str__(self): - exit_code = int(bool(self.hooks.failure_flag)) + exit_code = _int(_bool(self.hooks.failure_flag)) self.hooks.failure_flag = False # Guide following official VS Code doc for shell integration sequence: result = "" # For non-windows allow recent_command history. - if sys.platform != "win32": + if _sys.platform != "win32": result = "{soh}{command_executed}{command_line}{command_finished}{prompt_started}{stx}{prompt}{soh}{command_start}{stx}".format( soh="\001", stx="\002", command_executed="\x1b]633;C\x07", - command_line="\x1b]633;E;" + str(get_last_command()) + "\x07", - command_finished="\x1b]633;D;" + str(exit_code) + "\x07", + command_line="\x1b]633;E;" + _str(_get_last_command()) + "\x07", + command_finished="\x1b]633;D;" + _str(exit_code) + "\x07", prompt_started="\x1b]633;A\x07", - prompt=original_ps1, + prompt=_original_ps1, command_start="\x1b]633;B\x07", ) else: result = "{command_finished}{prompt_started}{prompt}{command_start}{command_executed}".format( - command_finished="\x1b]633;D;" + str(exit_code) + "\x07", + command_finished="\x1b]633;D;" + _str(exit_code) + "\x07", prompt_started="\x1b]633;A\x07", - prompt=original_ps1, + prompt=_original_ps1, command_start="\x1b]633;B\x07", command_executed="\x1b]633;C\x07", ) @@ -86,3 +107,4 @@ def __repr__(self): print("Cmd click to launch VS Code Native REPL") else: print("Ctrl click to launch VS Code Native REPL") + \ No newline at end of file diff --git a/python_files/tests/test_shell_integration.py b/python_files/tests/test_shell_integration.py index 7503a725b6d1..43250f96a7b3 100644 --- a/python_files/tests/test_shell_integration.py +++ b/python_files/tests/test_shell_integration.py @@ -1,12 +1,16 @@ import importlib import platform import sys +from pathlib import Path +from typing import Any from unittest.mock import Mock import pythonrc is_wsl = "microsoft-standard-WSL" in platform.release() +PYTHONRC_PATH = Path(pythonrc.__file__) + def test_decoration_success(): importlib.reload(pythonrc) @@ -81,3 +85,38 @@ def test_print_statement_non_darwin(monkeypatch): m.setattr("builtins.print", Mock()) importlib.reload(sys.modules["pythonrc"]) print.assert_any_call("Ctrl click to launch VS Code Native REPL") + + +def test_prompt_survives_shadowed_builtins_under_pythonstartup(): + """Regression test for #26039. + + PYTHONSTARTUP executes pythonrc.py's code directly inside __main__, not + as a normal module import, so PS1.__str__.__globals__ ends up being the + user's own namespace. `import pythonrc` (used by every other test in + this file) does not reproduce that, since it gives PS1 its own module + namespace instead. Simulate the real PYTHONSTARTUP path here: exec the + source into a stand-in __main__ dict, then shadow the names the prompt + depends on exactly as a user would (`int = 20`, `sys = 1`, etc.) and + confirm str(sys.ps1) still renders instead of raising. + """ + if sys.platform == "win32" or is_wsl: + return + + main_ns: dict[str, Any] = {"__name__": "__main__"} + source = PYTHONRC_PATH.read_text() + exec(compile(source, str(PYTHONRC_PATH), "exec"), main_ns) + + ps1 = main_ns["sys"].ps1 + assert str(ps1) # sanity: works before any shadowing + + for name, value in { + "int": 20, + "sys": 1, + "str": "nope", + "bool": "nope", + "original_ps1": "hijacked", + "get_last_command": "hijacked", + }.items(): + main_ns[name] = value + assert str(ps1), f"prompt failed to render after shadowing {name!r}" + \ No newline at end of file