Skip to content

Commit eb96141

Browse files
authored
Use shell integration to denote success/failure (#22487)
Resolves: #22486 Use shell integration to denote success/failure in Python REPL launched from VS Code. This would mean having the blue or red decorators based on whether or not user's command succeeded.
1 parent f6e1338 commit eb96141

File tree

4 files changed

+141
-2
lines changed

4 files changed

+141
-2
lines changed

pythonFiles/pythonrc.py

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import sys
2+
3+
original_ps1 = ">>>"
4+
5+
6+
class repl_hooks:
7+
def __init__(self):
8+
self.global_exit = None
9+
self.failure_flag = False
10+
self.original_excepthook = sys.excepthook
11+
self.original_displayhook = sys.displayhook
12+
sys.excepthook = self.my_excepthook
13+
sys.displayhook = self.my_displayhook
14+
15+
def my_displayhook(self, value):
16+
if value is None:
17+
self.failure_flag = False
18+
19+
self.original_displayhook(value)
20+
21+
def my_excepthook(self, type, value, traceback):
22+
self.global_exit = value
23+
self.failure_flag = True
24+
25+
self.original_excepthook(type, value, traceback)
26+
27+
28+
class ps1:
29+
hooks = repl_hooks()
30+
sys.excepthook = hooks.my_excepthook
31+
sys.displayhook = hooks.my_displayhook
32+
33+
# str will get called for every prompt with exit code to show success/failure
34+
def __str__(self):
35+
exit_code = 0
36+
if self.hooks.failure_flag:
37+
exit_code = 1
38+
else:
39+
exit_code = 0
40+
41+
# Guide following official VS Code doc for shell integration sequence:
42+
# result = "{command_finished}{prompt_started}{prompt}{command_start}{command_executed}".format(
43+
# command_finished="\x1b]633;D;" + str(exit_code) + "0\x07",
44+
# prompt_started="\x1b]633;A\x07",
45+
# prompt=original_ps1,
46+
# command_start="\x1b]633;B\x07",
47+
# command_executed="\x1b]633;C\x07",
48+
# )
49+
result = f"{chr(27)}]633;D;{exit_code}0{chr(7)}{chr(27)}]633;A{chr(7)}{original_ps1}{chr(27)}]633;B{chr(7)}{chr(27)}]633;C{chr(7)}"
50+
51+
return result
52+
53+
54+
sys.ps1 = ps1()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import importlib
2+
from unittest.mock import Mock
3+
4+
import pythonrc
5+
6+
7+
def test_decoration_success():
8+
importlib.reload(pythonrc)
9+
ps1 = pythonrc.ps1()
10+
11+
ps1.hooks.failure_flag = False
12+
result = str(ps1)
13+
assert result == "\x1b]633;D;00\x07\x1b]633;A\x07>>>\x1b]633;B\x07\x1b]633;C\x07"
14+
15+
16+
def test_decoration_failure():
17+
importlib.reload(pythonrc)
18+
ps1 = pythonrc.ps1()
19+
20+
ps1.hooks.failure_flag = True
21+
result = str(ps1)
22+
23+
assert result == "\x1b]633;D;10\x07\x1b]633;A\x07>>>\x1b]633;B\x07\x1b]633;C\x07"
24+
25+
26+
def test_displayhook_call():
27+
importlib.reload(pythonrc)
28+
pythonrc.ps1()
29+
mock_displayhook = Mock()
30+
31+
hooks = pythonrc.repl_hooks()
32+
hooks.original_displayhook = mock_displayhook
33+
34+
hooks.my_displayhook("mock_value")
35+
36+
mock_displayhook.assert_called_once_with("mock_value")
37+
38+
39+
def test_excepthook_call():
40+
importlib.reload(pythonrc)
41+
pythonrc.ps1()
42+
mock_excepthook = Mock()
43+
44+
hooks = pythonrc.repl_hooks()
45+
hooks.original_excepthook = mock_excepthook
46+
47+
hooks.my_excepthook("mock_type", "mock_value", "mock_traceback")
48+
mock_excepthook.assert_called_once_with("mock_type", "mock_value", "mock_traceback")

src/client/common/terminal/service.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
import { inject, injectable } from 'inversify';
5+
import * as path from 'path';
56
import { CancellationToken, Disposable, Event, EventEmitter, Terminal } from 'vscode';
67
import '../../common/extensions';
78
import { IInterpreterService } from '../../interpreter/contracts';
@@ -10,6 +11,8 @@ import { captureTelemetry } from '../../telemetry';
1011
import { EventName } from '../../telemetry/constants';
1112
import { ITerminalAutoActivation } from '../../terminals/types';
1213
import { ITerminalManager } from '../application/types';
14+
import { EXTENSION_ROOT_DIR } from '../constants';
15+
import { _SCRIPTS_DIR } from '../process/internal/scripts/constants';
1316
import { IConfigurationService, IDisposableRegistry } from '../types';
1417
import {
1518
ITerminalActivator,
@@ -28,6 +31,7 @@ export class TerminalService implements ITerminalService, Disposable {
2831
private terminalHelper: ITerminalHelper;
2932
private terminalActivator: ITerminalActivator;
3033
private terminalAutoActivator: ITerminalAutoActivation;
34+
private readonly envVarScript = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'pythonrc.py');
3135
public get onDidCloseTerminal(): Event<void> {
3236
return this.terminalClosed.event.bind(this.terminalClosed);
3337
}
@@ -69,14 +73,14 @@ export class TerminalService implements ITerminalService, Disposable {
6973
this.terminal!.show(preserveFocus);
7074
}
7175
}
72-
private async ensureTerminal(preserveFocus: boolean = true): Promise<void> {
76+
public async ensureTerminal(preserveFocus: boolean = true): Promise<void> {
7377
if (this.terminal) {
7478
return;
7579
}
7680
this.terminalShellType = this.terminalHelper.identifyTerminalShell(this.terminal);
7781
this.terminal = this.terminalManager.createTerminal({
7882
name: this.options?.title || 'Python',
79-
env: this.options?.env,
83+
env: { PYTHONSTARTUP: this.envVarScript },
8084
hideFromUser: this.options?.hideFromUser,
8185
});
8286
this.terminalAutoActivator.disableAutoActivation(this.terminal);

src/test/common/terminals/service.unit.test.ts

+33
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
// Licensed under the MIT License.
33

44
import { expect } from 'chai';
5+
import * as path from 'path';
56
import * as TypeMoq from 'typemoq';
67
import { Disposable, Terminal as VSCodeTerminal, WorkspaceConfiguration } from 'vscode';
78
import { ITerminalManager, IWorkspaceService } from '../../../client/common/application/types';
9+
import { EXTENSION_ROOT_DIR } from '../../../client/common/constants';
810
import { IPlatformService } from '../../../client/common/platform/types';
911
import { TerminalService } from '../../../client/common/terminal/service';
1012
import { ITerminalActivator, ITerminalHelper, TerminalShellType } from '../../../client/common/terminal/types';
@@ -158,6 +160,37 @@ suite('Terminal Service', () => {
158160
terminal.verify((t) => t.show(TypeMoq.It.isValue(false)), TypeMoq.Times.exactly(2));
159161
});
160162

163+
test('Ensure PYTHONSTARTUP is injected', async () => {
164+
service = new TerminalService(mockServiceContainer.object);
165+
terminalActivator
166+
.setup((h) => h.activateEnvironmentInTerminal(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
167+
.returns(() => Promise.resolve(true))
168+
.verifiable(TypeMoq.Times.once());
169+
terminalManager
170+
.setup((t) => t.createTerminal(TypeMoq.It.isAny()))
171+
.returns(() => terminal.object)
172+
.verifiable(TypeMoq.Times.atLeastOnce());
173+
const envVarScript = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'pythonrc.py');
174+
terminalManager
175+
.setup((t) =>
176+
t.createTerminal({
177+
name: TypeMoq.It.isAny(),
178+
env: TypeMoq.It.isObjectWith({ PYTHONSTARTUP: envVarScript }),
179+
hideFromUser: TypeMoq.It.isAny(),
180+
}),
181+
)
182+
.returns(() => terminal.object)
183+
.verifiable(TypeMoq.Times.atLeastOnce());
184+
await service.show();
185+
await service.show();
186+
await service.show();
187+
await service.show();
188+
189+
terminalHelper.verifyAll();
190+
terminalActivator.verifyAll();
191+
terminal.verify((t) => t.show(TypeMoq.It.isValue(true)), TypeMoq.Times.atLeastOnce());
192+
});
193+
161194
test('Ensure terminal is activated once after creation', async () => {
162195
service = new TerminalService(mockServiceContainer.object);
163196
terminalActivator

0 commit comments

Comments
 (0)