Skip to content

Commit da91e3f

Browse files
authored
Add blank lines to separate blocks of indented code (#1515)
Fixes #259
1 parent 5d6493b commit da91e3f

File tree

14 files changed

+226
-18
lines changed

14 files changed

+226
-18
lines changed

news/2 Fixes/259.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add blank lines to separate blocks of indented code (function defs, classes, and the like) so as to ensure the code can be run within a Python interactive prompt.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import ast
2+
import io
3+
import operator
4+
import os
5+
import sys
6+
import token
7+
import tokenize
8+
9+
10+
class Visitor(ast.NodeVisitor):
11+
def __init__(self, lines):
12+
self._lines = lines
13+
self.line_numbers_with_nodes = set()
14+
self.line_numbers_with_statements = []
15+
16+
def generic_visit(self, node):
17+
if hasattr(node, 'col_offset') and hasattr(node, 'lineno') and node.col_offset == 0:
18+
self.line_numbers_with_nodes.add(node.lineno)
19+
if isinstance(node, ast.stmt):
20+
self.line_numbers_with_statements.append(node.lineno)
21+
22+
ast.NodeVisitor.generic_visit(self, node)
23+
24+
25+
def _tokenize(source):
26+
"""Tokenize Python source code."""
27+
# Using an undocumented API as the documented one in Python 2.7 does not work as needed
28+
# cross-version.
29+
return tokenize.generate_tokens(io.StringIO(source).readline)
30+
31+
32+
def _indent_size(line):
33+
for index, char in enumerate(line):
34+
if not char.isspace():
35+
return index
36+
37+
38+
def _get_global_statement_blocks(source, lines):
39+
"""Return a list of all global statement blocks.
40+
41+
The list comprises of 3-item tuples that contain the starting line number,
42+
ending line number and whether the statement is a single line.
43+
44+
"""
45+
tree = ast.parse(source)
46+
visitor = Visitor(lines)
47+
visitor.visit(tree)
48+
49+
statement_ranges = []
50+
for index, line_number in enumerate(visitor.line_numbers_with_statements):
51+
remaining_line_numbers = visitor.line_numbers_with_statements[index+1:]
52+
end_line_number = len(lines) if len(remaining_line_numbers) == 0 else min(remaining_line_numbers) - 1
53+
current_statement_is_oneline = line_number == end_line_number
54+
55+
if len(statement_ranges) == 0:
56+
statement_ranges.append((line_number, end_line_number, current_statement_is_oneline))
57+
continue
58+
59+
previous_statement = statement_ranges[-1]
60+
previous_statement_is_oneline = previous_statement[2]
61+
if previous_statement_is_oneline and current_statement_is_oneline:
62+
statement_ranges[-1] = previous_statement[0], end_line_number, True
63+
else:
64+
statement_ranges.append((line_number, end_line_number, current_statement_is_oneline))
65+
66+
return statement_ranges
67+
68+
69+
def normalize_lines(source):
70+
"""Normalize blank lines for sending to the terminal.
71+
72+
Blank lines within a statement block are removed to prevent the REPL
73+
from thinking the block is finished. Newlines are added to separate
74+
top-level statements so that the REPL does not think there is a syntax
75+
error.
76+
77+
"""
78+
lines = source.splitlines(False)
79+
# Find out if we have any trailing blank lines
80+
has_blank_lines = len(lines[-1].strip()) == 0 or source.endswith(os.linesep)
81+
82+
# Step 1: Remove empty lines.
83+
tokens = _tokenize(source)
84+
newlines_indexes_to_remove = (spos[0] for (toknum, tokval, spos, epos, line) in tokens
85+
if len(line.strip()) == 0 and token.tok_name[toknum] == 'NL' and spos[0] == epos[0])
86+
87+
for line_number in reversed(list(newlines_indexes_to_remove)):
88+
del lines[line_number-1]
89+
90+
# Step 2: Add blank lines between each global statement block.
91+
# A consequtive single lines blocks of code will be treated as a single statement,
92+
# just to ensure we do not unnecessarily add too many blank lines.
93+
source = os.linesep.join(lines)
94+
tokens = _tokenize(source)
95+
dedent_indexes = (spos[0] for (toknum, tokval, spos, epos, line) in tokens
96+
if toknum == token.DEDENT and _indent_size(line) == 0)
97+
98+
global_statement_ranges = _get_global_statement_blocks(source, lines)
99+
100+
for line_number in filter(lambda x: x > 1, map(operator.itemgetter(0), reversed(global_statement_ranges))):
101+
lines.insert(line_number-1, '')
102+
103+
sys.stdout.write(os.linesep.join(lines) + (os.linesep if has_blank_lines else ''))
104+
sys.stdout.flush()
105+
106+
107+
if __name__ == '__main__':
108+
contents = sys.argv[1]
109+
try:
110+
default_encoding = sys.getdefaultencoding()
111+
contents = contents.encode(default_encoding, 'surrogateescape').decode(default_encoding, 'replace')
112+
except (UnicodeError, LookupError):
113+
pass
114+
if isinstance(contents, bytes):
115+
contents = contents.decode('utf8')
116+
normalize_lines(contents)

src/client/terminals/codeExecution/helper.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,41 @@
22
// Licensed under the MIT License.
33

44
import { inject, injectable } from 'inversify';
5+
import * as path from 'path';
56
import { Range, TextEditor, Uri } from 'vscode';
67
import { IApplicationShell, IDocumentManager } from '../../common/application/types';
7-
import { PYTHON_LANGUAGE } from '../../common/constants';
8+
import { EXTENSION_ROOT_DIR, PYTHON_LANGUAGE } from '../../common/constants';
89
import '../../common/extensions';
10+
import { IProcessService } from '../../common/process/types';
11+
import { IConfigurationService } from '../../common/types';
12+
import { IEnvironmentVariablesProvider } from '../../common/variables/types';
913
import { IServiceContainer } from '../../ioc/types';
1014
import { ICodeExecutionHelper } from '../types';
1115

1216
@injectable()
1317
export class CodeExecutionHelper implements ICodeExecutionHelper {
1418
private readonly documentManager: IDocumentManager;
1519
private readonly applicationShell: IApplicationShell;
20+
private readonly envVariablesProvider: IEnvironmentVariablesProvider;
21+
private readonly processService: IProcessService;
22+
private readonly configurationService: IConfigurationService;
1623
constructor(@inject(IServiceContainer) serviceContainer: IServiceContainer) {
1724
this.documentManager = serviceContainer.get<IDocumentManager>(IDocumentManager);
1825
this.applicationShell = serviceContainer.get<IApplicationShell>(IApplicationShell);
26+
this.envVariablesProvider = serviceContainer.get<IEnvironmentVariablesProvider>(IEnvironmentVariablesProvider);
27+
this.processService = serviceContainer.get<IProcessService>(IProcessService);
28+
this.configurationService = serviceContainer.get<IConfigurationService>(IConfigurationService);
1929
}
2030
public async normalizeLines(code: string, resource?: Uri): Promise<string> {
2131
try {
2232
if (code.trim().length === 0) {
2333
return '';
2434
}
25-
const regex = /(\n)([ \t]*\r?\n)([ \t]+\S+)/gm;
26-
return code.replace(regex, (_, a, b, c) => {
27-
return `${a}${c}`;
28-
});
35+
const env = await this.envVariablesProvider.getEnvironmentVariables(resource);
36+
const pythonPath = this.configurationService.getSettings(resource).pythonPath;
37+
const args = [path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'normalizeForInterpreter.py'), code];
38+
const proc = await this.processService.exec(pythonPath, args, { env, throwOnStdErr: true });
39+
return proc.stdout;
2940
} catch (ex) {
3041
console.error(ex, 'Python: Failed to normalize code for execution in terminal');
3142
return code;

src/test/pythonFiles/terminalExec/sample1_normalized.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
# Sample block 1
2+
23
def square(x):
34
return x**2
45

56
print('hello')
67
# Sample block 2
8+
79
a = 2
10+
811
if a < 2:
912
print('less than 2')
1013
else:
1114
print('more than 2')
1215

1316
print('hello')
14-
1517
# Sample block 3
18+
1619
for i in range(5):
1720
print(i)
1821
print(i)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
if True:
22
print(1)
33
print(2)
4+
45
print(3)

src/test/pythonFiles/terminalExec/sample3_raw.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
print(1)
33

44
print(2)
5+
56
print(3)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
if True:
2+
print(1)
3+
else: print(2)
4+
5+
print('🔨')
6+
print(3)
7+
print(3)
8+
9+
if True:
10+
print(1)
11+
else: print(2)
12+
13+
if True:
14+
print(1)
15+
else: print(2)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
if True:
2+
print(1)
3+
else: print(2)
4+
print('🔨')
5+
print(3)
6+
print(3)
7+
if True:
8+
print(1)
9+
else: print(2)
10+
if True:
11+
print(1)
12+
else: print(2)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
if True:
2+
print(1)
3+
print(1)
4+
else:
5+
print(2)
6+
print(2)
7+
8+
print(3)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
if True:
2+
print(1)
3+
4+
print(1)
5+
else:
6+
print(2)
7+
8+
print(2)
9+
print(3)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import sys
2+
print(sys.executable)
3+
print("1234")
4+
print(1)
5+
print(2)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import sys
2+
3+
print(sys.executable)
4+
5+
print("1234")
6+
7+
print(1)
8+
print(2)

src/test/terminals/codeExecution/helper.test.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@ import * as TypeMoq from 'typemoq';
1111
import { Range, Selection, TextDocument, TextEditor, TextLine, Uri } from 'vscode';
1212
import { IApplicationShell, IDocumentManager } from '../../../client/common/application/types';
1313
import { EXTENSION_ROOT_DIR, PYTHON_LANGUAGE } from '../../../client/common/constants';
14+
import { BufferDecoder } from '../../../client/common/process/decoder';
15+
import { ProcessService } from '../../../client/common/process/proc';
16+
import { IProcessService } from '../../../client/common/process/types';
17+
import { IConfigurationService, IPythonSettings } from '../../../client/common/types';
18+
import { IEnvironmentVariablesProvider } from '../../../client/common/variables/types';
1419
import { IServiceContainer } from '../../../client/ioc/types';
1520
import { CodeExecutionHelper } from '../../../client/terminals/codeExecution/helper';
1621
import { ICodeExecutionHelper } from '../../../client/terminals/types';
22+
import { PYTHON_PATH } from '../../common';
1723

1824
const TEST_FILES_PATH = path.join(EXTENSION_ROOT_DIR, 'src', 'test', 'pythonFiles', 'terminalExec');
1925

@@ -24,12 +30,24 @@ suite('Terminal - Code Execution Helper', () => {
2430
let helper: ICodeExecutionHelper;
2531
let document: TypeMoq.IMock<TextDocument>;
2632
let editor: TypeMoq.IMock<TextEditor>;
33+
let processService: TypeMoq.IMock<IProcessService>;
34+
let configService: TypeMoq.IMock<IConfigurationService>;
2735
setup(() => {
2836
const serviceContainer = TypeMoq.Mock.ofType<IServiceContainer>();
2937
documentManager = TypeMoq.Mock.ofType<IDocumentManager>();
3038
applicationShell = TypeMoq.Mock.ofType<IApplicationShell>();
39+
const envVariablesProvider = TypeMoq.Mock.ofType<IEnvironmentVariablesProvider>();
40+
processService = TypeMoq.Mock.ofType<IProcessService>();
41+
configService = TypeMoq.Mock.ofType<IConfigurationService>();
42+
const pythonSettings = TypeMoq.Mock.ofType<IPythonSettings>();
43+
pythonSettings.setup(p => p.pythonPath).returns(() => PYTHON_PATH);
44+
configService.setup(c => c.getSettings(TypeMoq.It.isAny())).returns(() => pythonSettings.object);
45+
envVariablesProvider.setup(e => e.getEnvironmentVariables(TypeMoq.It.isAny())).returns(() => Promise.resolve({}));
3146
serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IDocumentManager), TypeMoq.It.isAny())).returns(() => documentManager.object);
3247
serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IApplicationShell), TypeMoq.It.isAny())).returns(() => applicationShell.object);
48+
serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IEnvironmentVariablesProvider), TypeMoq.It.isAny())).returns(() => envVariablesProvider.object);
49+
serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IProcessService), TypeMoq.It.isAny())).returns(() => processService.object);
50+
serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IConfigurationService), TypeMoq.It.isAny())).returns(() => configService.object);
3351
helper = new CodeExecutionHelper(serviceContainer.object);
3452

3553
document = TypeMoq.Mock.ofType<TextDocument>();
@@ -38,18 +56,23 @@ suite('Terminal - Code Execution Helper', () => {
3856
});
3957

4058
async function ensureBlankLinesAreRemoved(source: string, expectedSource: string) {
59+
const actualProcessService = new ProcessService(new BufferDecoder());
60+
processService.setup(p => p.exec(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny()))
61+
.returns((file, args, options) => {
62+
return actualProcessService.exec.apply(actualProcessService, [file, args, options]);
63+
});
4164
const normalizedZCode = await helper.normalizeLines(source);
4265
expect(normalizedZCode).to.be.equal(expectedSource);
4366
}
4467
test('Ensure blank lines are NOT removed when code is not indented (simple)', async () => {
45-
const code = ['import sys', '', 'print(sys.executable)', '', 'print("1234")', '', 'print(1)', 'print(2)'];
46-
const expectedCode = code.join(EOL);
68+
const code = ['import sys', '', '', '', 'print(sys.executable)', '', 'print("1234")', '', '', 'print(1)', 'print(2)'];
69+
const expectedCode = code.filter(line => line.trim().length > 0).join(EOL);
4770
await ensureBlankLinesAreRemoved(code.join(EOL), expectedCode);
4871
});
49-
['sample1', 'sample2', 'sample3', 'sample4', 'sample5'].forEach(fileName => {
50-
test(`Ensure blank lines are removed (${fileName})`, async () => {
51-
const code = await fs.readFile(path.join(TEST_FILES_PATH, `${fileName}_raw.py`), 'utf8');
52-
const expectedCode = await fs.readFile(path.join(TEST_FILES_PATH, `${fileName}_normalized.py`), 'utf8');
72+
['', '1', '2', '3', '4', '5', '6', '7'].forEach(fileNameSuffix => {
73+
test(`Ensure blank lines are removed (Sample${fileNameSuffix})`, async () => {
74+
const code = await fs.readFile(path.join(TEST_FILES_PATH, `sample${fileNameSuffix}_raw.py`), 'utf8');
75+
const expectedCode = await fs.readFile(path.join(TEST_FILES_PATH, `sample${fileNameSuffix}_normalized.py`), 'utf8');
5376
await ensureBlankLinesAreRemoved(code, expectedCode);
5477
});
5578
// test(`Ensure blank lines are removed, including leading empty lines (${fileName})`, async () => {
@@ -58,11 +81,6 @@ suite('Terminal - Code Execution Helper', () => {
5881
// await ensureBlankLinesAreRemoved(['', '', ''].join(EOL) + EOL + code, expectedCode);
5982
// });
6083
});
61-
test('Ensure blank lines are removed (sample2)', async () => {
62-
const code = await fs.readFile(path.join(TEST_FILES_PATH, 'sample2_raw.py'), 'utf8');
63-
const expectedCode = await fs.readFile(path.join(TEST_FILES_PATH, 'sample2_normalized.py'), 'utf8');
64-
await ensureBlankLinesAreRemoved(code, expectedCode);
65-
});
6684
test('Display message if there\s no active file', async () => {
6785
documentManager.setup(doc => doc.activeTextEditor).returns(() => undefined);
6886

src/test/terminals/codeExecution/terminalCodeExec.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { ICodeExecutionService } from '../../../client/terminals/types';
1818
import { PYTHON_PATH } from '../../common';
1919

2020
// tslint:disable-next-line:max-func-body-length
21-
suite('Terminal Code Execution', () => {
21+
suite('Terminal - Code Execution', () => {
2222
// tslint:disable-next-line:max-func-body-length
2323
['Terminal Execution', 'Repl Execution', 'Django Execution'].forEach(testSuiteName => {
2424
let terminalSettings: TypeMoq.IMock<ITerminalSettings>;

0 commit comments

Comments
 (0)