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)

0 commit comments

Comments
 (0)