Skip to content

Commit b46ff69

Browse files
authored
PyREPL: Correctly handle OSC codes (#26175)
Related python/cpython#157932 Related #25467 Modifies `_pyrepl.utils.ANSI_ESCAPE_SEQUENCE` to correctly support VSCode's OSC sequences and prevent cursor misplacing when using the REPL inside vscode. Refactorings for easier maintainability
1 parent 0f81e0e commit b46ff69

2 files changed

Lines changed: 123 additions & 63 deletions

File tree

‎python_files/pythonrc.py‎

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
def _initialize():
22
import platform
33
import sys
4-
from enum import Enum
54

65
if sys.platform != "win32":
76
import readline
87

9-
original_ps1 = ">>> "
10-
is_wsl = "microsoft-standard-WSL" in platform.release()
8+
# Override regex to support OSC codes
9+
if sys.version_info >= (3, 13):
10+
import re
11+
12+
import _pyrepl.utils
13+
14+
_pyrepl.utils.ANSI_ESCAPE_SEQUENCE = re.compile(
15+
r"\x1b(?:\[[ -@]*[A-~]|\][^\x1b\x07]*(?:\x07|\x1b\\))"
16+
)
1117

18+
original_ps1 = ">>> "
1219
# PYTHONSTARTUP executes this file's code inside the user's __main__
1320
# namespace, so PS1.__str__'s globals are the user's globals. If the
1421
# user later shadows a name we rely on at prompt-render time (e.g.
@@ -23,36 +30,54 @@ def _initialize():
2330
_bool = bool
2431
_str = str
2532

26-
class ShellIntegrationSequence(str, Enum):
27-
SOH = "\001"
28-
STX = "\002"
29-
COMMAND_EXECUTED = "\x1b]633;C\x07"
30-
COMMAND_LINE = "\x1b]633;E;"
31-
COMMAND_FINISHED = "\x1b]633;D;"
32-
PROMPT_STARTED = "\x1b]633;A\x07"
33-
COMMAND_START = "\x1b]633;B\x07"
34-
TERMINATOR = "\x07"
35-
36-
def __str__(self):
37-
return self.value
33+
# https://code.visualstudio.com/docs/terminal/shell-integration#_supported-escape-sequences
34+
class ShellIntegrationSequence:
35+
soh = "\001"
36+
stx = "\002"
37+
template = "\x1b]633;{}\x07"
38+
39+
# Before the prompt (>>>) is displayed
40+
@classmethod
41+
def prompt_start(cls) -> str:
42+
return cls.template.format("A")
43+
44+
# After the prompt (>>>) is displayed
45+
@classmethod
46+
def prompt_end(cls) -> str:
47+
return cls.template.format("B")
48+
49+
# After the user has typed a command but before it is executed
50+
@classmethod
51+
def pre_execution(cls) -> str:
52+
return cls.template.format("C")
53+
54+
@classmethod
55+
def execution_finished(cls, exit_code: int) -> str:
56+
"""Mark execution as finished with its exit code."""
57+
return cls.template.format(f"D;{exit_code}")
58+
59+
@classmethod
60+
def command_line(cls, command: object) -> str:
61+
"""Explicitly set the command line interpreted by the shell."""
62+
return cls.template.format(f"E;{command}")
3863

3964
class REPLHooks:
4065
def __init__(self):
4166
self.global_exit = None
42-
self.failure_flag = False
67+
self.last_failure_flag = False
4368
self.original_excepthook = sys.excepthook
4469
self.original_displayhook = sys.displayhook
4570
sys.excepthook = self.vscode_excepthook
4671
sys.displayhook = self.vscode_displayhook
4772

4873
def vscode_displayhook(self, value):
4974
if value is None:
50-
self.failure_flag = False
75+
self.last_failure_flag = False
5176
self.original_displayhook(value)
5277

5378
def vscode_excepthook(self, type_, value, traceback):
5479
self.global_exit = value
55-
self.failure_flag = True
80+
self.last_failure_flag = True
5681
self.original_excepthook(type_, value, traceback)
5782

5883
def get_last_command():
@@ -67,43 +92,40 @@ class PS1:
6792

6893
# str will get called for every prompt with exit code to show success/failure
6994
def __str__(self):
70-
exit_code = _int(_bool(self.hooks.failure_flag))
71-
self.hooks.failure_flag = False
95+
last_exit_code = _int(_bool(self.hooks.last_failure_flag))
96+
self.hooks.last_failure_flag = False
7297
# Guide following official VS Code doc for shell integration sequence:
7398
result = ""
7499
# For non-windows allow recent_command history.
100+
# fmt: off
75101
if sys.platform != "win32":
76-
result = "{soh}{command_executed}{command_line}{command_finished}{prompt_started}{stx}{prompt}{soh}{command_start}{stx}".format(
77-
soh=ShellIntegrationSequence.SOH,
78-
stx=ShellIntegrationSequence.STX,
79-
command_executed=ShellIntegrationSequence.COMMAND_EXECUTED,
80-
command_line=ShellIntegrationSequence.COMMAND_LINE
81-
+ _str(get_last_command())
82-
+ ShellIntegrationSequence.TERMINATOR,
83-
command_finished=ShellIntegrationSequence.COMMAND_FINISHED
84-
+ _str(exit_code)
85-
+ ShellIntegrationSequence.TERMINATOR,
86-
prompt_started=ShellIntegrationSequence.PROMPT_STARTED,
102+
result = "{soh}{pre_execution}{command_line}{execution_finished}{prompt_start}{stx}{prompt}{soh}{prompt_end}{stx}".format( # noqa: UP032
103+
soh=ShellIntegrationSequence.soh,
104+
pre_execution=ShellIntegrationSequence.pre_execution(),
105+
command_line=ShellIntegrationSequence.command_line(get_last_command()),
106+
execution_finished=ShellIntegrationSequence.execution_finished(last_exit_code),
107+
prompt_start=ShellIntegrationSequence.prompt_start(),
108+
stx=ShellIntegrationSequence.stx,
87109
prompt=original_ps1,
88-
command_start=ShellIntegrationSequence.COMMAND_START,
110+
prompt_end=ShellIntegrationSequence.prompt_end(),
89111
)
90112
else:
91-
result = "{command_finished}{prompt_started}{prompt}{command_start}{command_executed}".format(
92-
command_finished=ShellIntegrationSequence.COMMAND_FINISHED
93-
+ _str(exit_code)
94-
+ ShellIntegrationSequence.TERMINATOR,
95-
prompt_started=ShellIntegrationSequence.PROMPT_STARTED,
113+
result = "{execution_finished}{prompt_start}{prompt}{prompt_end}{pre_execution}".format( # noqa: UP032
114+
execution_finished=ShellIntegrationSequence.execution_finished(last_exit_code),
115+
prompt_start=ShellIntegrationSequence.prompt_start(),
96116
prompt=original_ps1,
97-
command_start=ShellIntegrationSequence.COMMAND_START,
98-
command_executed=ShellIntegrationSequence.COMMAND_EXECUTED,
117+
prompt_end=ShellIntegrationSequence.prompt_end(),
118+
pre_execution=ShellIntegrationSequence.pre_execution(),
99119
)
120+
# fmt: on
100121

101122
return result
102123

103124
def __repr__(self):
104125
return "<Custom PS1 for VS Code Python Shell Integration>"
105126

106-
if sys.platform != "win32" and (not is_wsl):
127+
is_wsl = "microsoft-standard-WSL" in platform.release()
128+
if not is_wsl and (sys.platform != "win32" or sys.version_info >= (3, 13)):
107129
sys.ps1 = PS1()
108130

109131
ctrl_key = "Cmd" if sys.platform == "darwin" else "Ctrl"

‎python_files/tests/test_shell_integration.py‎

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,40 +8,48 @@
88
import pythonrc
99

1010
is_wsl = "microsoft-standard-WSL" in platform.release()
11+
prompt_is_installed = not is_wsl and (sys.platform != "win32" or sys.version_info >= (3, 13))
1112

1213
PYTHONRC_PATH = Path(pythonrc.__file__)
1314

1415

1516
class _Hooks(Protocol):
16-
failure_flag: bool
17+
last_failure_flag: bool
1718

1819

1920
class _PS1(Protocol):
2021
hooks: _Hooks
2122

2223

24+
def _expected_prompt(exit_code: int) -> str:
25+
if sys.platform == "win32":
26+
return f"\x1b]633;D;{exit_code}\x07\x1b]633;A\x07>>> \x1b]633;B\x07\x1b]633;C\x07"
27+
return (
28+
"\x01\x1b]633;C\x07\x1b]633;E;None\x07"
29+
f"\x1b]633;D;{exit_code}\x07\x1b]633;A\x07\x02>>> \x01\x1b]633;B\x07\x02"
30+
)
31+
32+
2333
def test_decoration_success():
2434
importlib.reload(pythonrc)
25-
if sys.platform != "win32" and (not is_wsl):
26-
ps1 = cast("_PS1", sys.ps1)
27-
ps1.hooks.failure_flag = False
28-
result = str(ps1)
29-
assert (
30-
result
31-
== "\x01\x1b]633;C\x07\x1b]633;E;None\x07\x1b]633;D;0\x07\x1b]633;A\x07\x02>>> \x01\x1b]633;B\x07\x02"
32-
)
35+
if not prompt_is_installed:
36+
return
37+
38+
ps1 = cast("_PS1", sys.ps1)
39+
ps1.hooks.last_failure_flag = False
40+
41+
assert str(ps1) == _expected_prompt(0)
3342

3443

3544
def test_decoration_failure():
3645
importlib.reload(pythonrc)
37-
if sys.platform != "win32" and (not is_wsl):
38-
ps1 = cast("_PS1", sys.ps1)
39-
ps1.hooks.failure_flag = True
40-
result = str(ps1)
41-
assert (
42-
result
43-
== "\x01\x1b]633;C\x07\x1b]633;E;None\x07\x1b]633;D;1\x07\x1b]633;A\x07\x02>>> \x01\x1b]633;B\x07\x02"
44-
)
46+
if not prompt_is_installed:
47+
return
48+
49+
ps1 = cast("_PS1", sys.ps1)
50+
ps1.hooks.last_failure_flag = True
51+
52+
assert str(ps1) == _expected_prompt(1)
4553

4654

4755
def test_displayhook_call():
@@ -73,6 +81,40 @@ def test_does_not_pollute_namespace():
7381
assert not [name for name in vars(pythonrc) if not name.startswith("__")]
7482

7583

84+
def test_replacement_regex_removes_bel_terminated_osc():
85+
if sys.version_info < (3, 13):
86+
return
87+
88+
pyrepl_utils = importlib.import_module("_pyrepl.utils")
89+
importlib.reload(pythonrc)
90+
91+
assert pyrepl_utils.ANSI_ESCAPE_SEQUENCE.sub("", "before\x1b]633;A\x07after") == ("beforeafter")
92+
93+
94+
def test_replacement_regex_removes_st_terminated_osc():
95+
if sys.version_info < (3, 13):
96+
return
97+
98+
pyrepl_utils = importlib.import_module("_pyrepl.utils")
99+
importlib.reload(pythonrc)
100+
101+
assert pyrepl_utils.ANSI_ESCAPE_SEQUENCE.sub("", "before\x1b]633;A\x1b\\after") == (
102+
"beforeafter"
103+
)
104+
105+
106+
def test_replacement_regex_preserves_csi_handling():
107+
if sys.version_info < (3, 13):
108+
return
109+
110+
pyrepl_utils = importlib.import_module("_pyrepl.utils")
111+
importlib.reload(pythonrc)
112+
113+
assert pyrepl_utils.ANSI_ESCAPE_SEQUENCE.sub("", "before\x1b[31mred\x1b[0mafter") == (
114+
"beforeredafter"
115+
)
116+
117+
76118
def test_prompt_survives_shadowed_builtins_under_pythonstartup():
77119
# PYTHONSTARTUP executes pythonrc's source directly inside the real
78120
# REPL's __main__ namespace, not as an imported module. The tests
@@ -81,7 +123,7 @@ def test_prompt_survives_shadowed_builtins_under_pythonstartup():
81123
# the real PYTHONSTARTUP path by exec-ing the source into a synthetic
82124
# __main__-like namespace, then shadow the names PS1 relies on at
83125
# prompt-render time and confirm rendering the prompt still works.
84-
if sys.platform == "win32" or is_wsl:
126+
if not prompt_is_installed:
85127
return
86128

87129
source = PYTHONRC_PATH.read_text(encoding="utf-8")
@@ -100,11 +142,7 @@ def test_prompt_survives_shadowed_builtins_under_pythonstartup():
100142
)
101143

102144
ps1 = cast("_PS1", sys.ps1)
103-
result = str(ps1)
104-
assert (
105-
result
106-
== "\x01\x1b]633;C\x07\x1b]633;E;None\x07\x1b]633;D;0\x07\x1b]633;A\x07\x02>>> \x01\x1b]633;B\x07\x02"
107-
)
145+
assert str(ps1) == _expected_prompt(0)
108146

109147

110148
if sys.platform == "darwin":

0 commit comments

Comments
 (0)