Skip to content

Commit 0beeb33

Browse files
committed
improving code parsing
1 parent 2dffbe1 commit 0beeb33

File tree

3 files changed

+169
-62
lines changed

3 files changed

+169
-62
lines changed

devtools/debug.py

Lines changed: 59 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import warnings
55
from pathlib import Path
66
from textwrap import dedent
7-
from typing import Generator, List
7+
from typing import Generator, List, Optional, Tuple
88

99
__all__ = ['Debug', 'debug']
1010
CWD = Path('.').resolve()
@@ -66,6 +66,11 @@ class Debug:
6666
output_class = DebugOutput
6767
# 50 lines should be enough to make sure we always get the entire function definition
6868
frame_context_length = 50
69+
complex_nodes = (
70+
ast.Call,
71+
ast.IfExp, ast.BoolOp, ast.BinOp, ast.Compare,
72+
ast.DictComp, ast.ListComp, ast.SetComp, ast.GeneratorExp
73+
)
6974

7075
def __call__(self, *args, **kwargs):
7176
print(self._process(args, kwargs, r'debug *\('), flush=True)
@@ -88,26 +93,23 @@ def _process(self, args, kwargs, func_regex):
8893
# happens if filename path is not within CWD
8994
pass
9095

91-
call_lines = []
92-
# print(call_frame)
93-
# from pprint import pprint
94-
# pprint(call_frame.code_context)
9596
if call_frame.code_context:
96-
for line in range(call_frame.index, 0, -1):
97-
new_line = call_frame.code_context[line]
98-
call_lines.append(new_line)
99-
if re.search(func_regex, new_line):
100-
break
101-
call_lines.reverse()
102-
lineno = call_frame.lineno - len(call_lines) + 1
97+
func_ast, code_lines, lineno = self._parse_code(call_frame, func_regex, filename)
98+
if func_ast:
99+
arguments = list(self._process_args(func_ast, code_lines, args, kwargs))
100+
else:
101+
# parsing failed
102+
arguments = list(self._args_inspection_failed(args, kwargs))
103103
else:
104-
lineno = call_frame.lineno - len(call_lines)
104+
lineno = call_frame.lineno
105+
warnings.warn('no code context for debug call, code inspection impossible', RuntimeWarning)
106+
arguments = list(self._args_inspection_failed(args, kwargs))
105107

106108
return self.output_class(
107109
filename=filename,
108110
lineno=lineno,
109111
frame=call_frame.function,
110-
arguments=list(self._process_args(call_lines, args, kwargs, call_frame))
112+
arguments=arguments
111113
)
112114

113115
def _args_inspection_failed(self, args, kwargs):
@@ -116,44 +118,12 @@ def _args_inspection_failed(self, args, kwargs):
116118
for name, value in kwargs.items():
117119
yield self.output_class.arg_class(value, name=name)
118120

119-
def _process_args(self, call_lines, args, kwargs, call_frame) -> Generator[DebugArgument, None, None]: # noqa: C901
120-
if not call_lines:
121-
warnings.warn('no code context for debug call, code inspection impossible', RuntimeWarning)
122-
yield from self._args_inspection_failed(args, kwargs)
123-
return
124-
125-
code = dedent(''.join(call_lines))
126-
# print(code)
127-
try:
128-
func_ast = ast.parse(code).body[0].value
129-
except SyntaxError as e1:
130-
# if the trailing bracket of the function is on a new line eg.
131-
# debug(
132-
# foo, bar,
133-
# )
134-
# inspect ignores it with index and we have to add it back
135-
code2 = code + call_frame.code_context[call_frame.index + 1]
136-
try:
137-
func_ast = ast.parse(code2).body[0].value
138-
except SyntaxError:
139-
warnings.warn('error passing code:\n"{}"\nError: {}'.format(code, e1), SyntaxWarning)
140-
yield from self._args_inspection_failed(args, kwargs)
141-
return
142-
else:
143-
code = code2
144-
145-
code_lines = [l for l in code.split('\n') if l]
146-
# this removes the trailing bracket from the lines of code meaning it doesn't appear in the
147-
# representation of the last argument
148-
code_lines[-1] = code_lines[-1][:-1]
149-
121+
def _process_args(self, func_ast, code_lines, args, kwargs) -> Generator[DebugArgument, None, None]: # noqa: C901
150122
arg_offsets = list(self._get_offsets(func_ast))
151123
for arg, ast_node, i in zip(args, func_ast.args, range(1000)):
152124
if isinstance(ast_node, ast.Name):
153125
yield self.output_class.arg_class(arg, name=ast_node.id)
154-
elif isinstance(ast_node, (ast.Str, ast.Bytes, ast.Num, ast.List, ast.Dict, ast.Set)):
155-
yield self.output_class.arg_class(arg)
156-
elif isinstance(ast_node, (ast.Call, ast.Compare)):
126+
elif isinstance(ast_node, self.complex_nodes):
157127
# TODO replace this hack with astor when it get's round to a new release
158128
start_line, start_col = ast_node.lineno - 1, ast_node.col_offset
159129
end_line, end_col = len(code_lines) - 1, None
@@ -170,7 +140,6 @@ def _process_args(self, call_lines, args, kwargs, call_frame) -> Generator[Debug
170140
)
171141
yield self.output_class.arg_class(arg, name=' '.join(name_lines).strip(' ,'))
172142
else:
173-
warnings.warn('Unknown type: {}'.format(ast.dump(ast_node)), RuntimeWarning)
174143
yield self.output_class.arg_class(arg)
175144

176145
kw_arg_names = {}
@@ -180,6 +149,47 @@ def _process_args(self, call_lines, args, kwargs, call_frame) -> Generator[Debug
180149
for name, value in kwargs.items():
181150
yield self.output_class.arg_class(value, name=name, variable=kw_arg_names.get(name))
182151

152+
def _parse_code(self, call_frame, func_regex, filename) -> Tuple[Optional[ast.AST], Optional[List[str]], int]:
153+
call_lines = []
154+
for line in range(call_frame.index, 0, -1):
155+
new_line = call_frame.code_context[line]
156+
call_lines.append(new_line)
157+
if re.search(func_regex, new_line):
158+
break
159+
call_lines.reverse()
160+
lineno = call_frame.lineno - len(call_lines) + 1
161+
162+
original_code = code = dedent(''.join(call_lines))
163+
func_ast = None
164+
tail_index = call_frame.index
165+
try:
166+
func_ast = ast.parse(code, filename=filename).body[0].value
167+
except SyntaxError as e1:
168+
# if the trailing bracket(s) of the function is/are on a new line eg.
169+
# debug(
170+
# foo, bar,
171+
# )
172+
# inspect ignores it when setting index and we have to add it back
173+
for extra in range(2, 6):
174+
extra_lines = call_frame.code_context[tail_index + 1:tail_index + extra]
175+
code = dedent(''.join(call_lines + extra_lines))
176+
try:
177+
func_ast = ast.parse(code).body[0].value
178+
except SyntaxError:
179+
pass
180+
else:
181+
break
182+
183+
if not func_ast:
184+
warnings.warn('error passing code:\n"{}"\nError: {}'.format(original_code, e1), SyntaxWarning)
185+
return None, None, lineno
186+
187+
code_lines = [l for l in code.split('\n') if l]
188+
# this removes the trailing bracket from the lines of code meaning it doesn't appear in the
189+
# representation of the last argument
190+
code_lines[-1] = code_lines[-1][:-1]
191+
return func_ast, code_lines, lineno
192+
183193
@classmethod
184194
def _get_offsets(cls, func_ast):
185195
for arg in func_ast.args:

tests/test_expr_render.py

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,52 @@ def test_simple():
1515
v = debug.format(len(a))
1616
s = re.sub(':\d{2,}', ':<line no>', str(v))
1717
# print(s)
18-
assert s == (
18+
assert (
1919
'tests/test_expr_render.py:<line no> test_simple: len(a) = 3 (int)'
20+
) == s
21+
22+
23+
def test_exotic_types():
24+
aa = [1, 2, 3]
25+
v = debug.format(
26+
sum(aa),
27+
1 == 2,
28+
1 < 2,
29+
1 << 2,
30+
't' if True else 'f',
31+
1 or 2,
32+
[a for a in aa],
33+
{a for a in aa},
34+
{a: a + 1 for a in aa},
35+
(a for a in aa),
2036
)
37+
s = re.sub(r':\d{2,}', ':<line no>', str(v))
38+
s = re.sub(r'(at 0x)\w+', r'\1<hash>', s)
39+
print(s)
40+
# list and generator comprehensions are wrong because ast is wrong, see https://bugs.python.org/issue31241
41+
assert (
42+
'tests/test_expr_render.py:<line no> test_exotic_types\n'
43+
' sum(aa) = 6 (int)\n'
44+
' 1 == 2 = False (bool)\n'
45+
' 1 < 2 = True (bool)\n'
46+
' 1 << 2 = 4 (int)\n'
47+
' \'t\' if True else \'f\' = "t" (str) len=1\n'
48+
' 1 or 2, [ = 1 (int)\n'
49+
' a for a in aa] = [1, 2, 3] (list)\n'
50+
' {a for a in aa} = {1, 2, 3} (set)\n'
51+
' {a: a + 1 for a in aa}, ( = {1: 2, 2: 3, 3: 4} (dict)\n'
52+
' a for a in aa) = <generator object test_exotic_types.<locals>.<genexpr> at 0x<hash>> (generator)'
53+
) == s
2154

2255

2356
def test_newline():
2457
v = debug.format(
2558
foobar(1, 2, 3))
2659
s = re.sub(':\d{2,}', ':<line no>', str(v))
2760
# print(s)
28-
assert s == (
61+
assert (
2962
'tests/test_expr_render.py:<line no> test_newline: foobar(1, 2, 3) = 6 (int)'
30-
)
63+
) == s
3164

3265

3366
def test_trailing_bracket():
@@ -36,9 +69,9 @@ def test_trailing_bracket():
3669
)
3770
s = re.sub(':\d{2,}', ':<line no>', str(v))
3871
# print(s)
39-
assert s == (
72+
assert (
4073
'tests/test_expr_render.py:<line no> test_trailing_bracket: foobar(1, 2, 3) = 6 (int)'
41-
)
74+
) == s
4275

4376

4477
def test_multiline():
@@ -49,9 +82,9 @@ def test_multiline():
4982
)
5083
s = re.sub(':\d{2,}', ':<line no>', str(v))
5184
# print(s)
52-
assert s == (
85+
assert (
5386
'tests/test_expr_render.py:<line no> test_multiline: foobar(1, 2, 3) = 6 (int)'
54-
)
87+
) == s
5588

5689

5790
def test_multiline_trailing_bracket():
@@ -60,9 +93,9 @@ def test_multiline_trailing_bracket():
6093
))
6194
s = re.sub(':\d{2,}', ':<line no>', str(v))
6295
# print(s)
63-
assert s == (
96+
assert (
6497
'tests/test_expr_render.py:<line no> test_multiline_trailing_bracket: foobar(1, 2, 3 ) = 6 (int)'
65-
)
98+
) == s
6699

67100

68101
@pytest.mark.skipif(sys.version_info < (3, 6), reason='kwarg order is not guaranteed for 3.5')
@@ -73,13 +106,12 @@ def test_kwargs():
73106
b=7
74107
)
75108
s = re.sub(':\d{2,}', ':<line no>', str(v))
76-
assert s == (
109+
assert (
77110
'tests/test_expr_render.py:<line no> test_kwargs\n'
78111
' foobar(1, 2, 3) = 6 (int)\n'
79112
' a = 6 (int)\n'
80113
' b = 7 (int)'
81-
82-
)
114+
) == s
83115

84116

85117
@pytest.mark.skipif(sys.version_info < (3, 6), reason='kwarg order is not guaranteed for 3.5')
@@ -91,10 +123,47 @@ def test_kwargs_multiline():
91123
b=7
92124
)
93125
s = re.sub(':\d{2,}', ':<line no>', str(v))
94-
assert s == (
126+
assert (
95127
'tests/test_expr_render.py:<line no> test_kwargs_multiline\n'
96128
' foobar(1, 2, 3) = 6 (int)\n'
97129
' a = 6 (int)\n'
98130
' b = 7 (int)'
131+
) == s
132+
99133

134+
def test_multiple_trailing_lines():
135+
v = debug.format(
136+
foobar(
137+
1, 2, 3
138+
),
100139
)
140+
s = re.sub(':\d{2,}', ':<line no>', str(v))
141+
assert (
142+
'tests/test_expr_render.py:<line no> test_multiple_trailing_lines: foobar( 1, 2, 3 ) = 6 (int)'
143+
) == s
144+
145+
146+
def test_syntax_warning():
147+
# exceed the 4 extra lines which are normally checked
148+
with pytest.warns(SyntaxWarning) as warning_checker:
149+
v = debug.format(
150+
abs(
151+
abs(
152+
abs(
153+
abs(
154+
-1
155+
)
156+
)
157+
)
158+
)
159+
)
160+
assert len(warning_checker) == 1
161+
warning = warning_checker.list[0]
162+
print(warning.message)
163+
assert 'Error: unexpected EOF while parsing (test_expr_render.py' in str(warning.message)
164+
# check only the original code is included in the warning
165+
assert '-1\n"' in str(warning.message)
166+
s = re.sub(':\d{2,}', ':<line no>', str(v))
167+
assert (
168+
'tests/test_expr_render.py:<line no> test_syntax_warning: 1 (int)'
169+
) == s

tests/test_main.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def test_kwargs():
7777

7878

7979
def test_kwargs_orderless():
80+
# for python3.5
8081
a = 'variable'
8182
v = debug.format(first=a, second='literal')
8283
s = re.sub(':\d{2,}', ':<line no>', str(v))
@@ -87,13 +88,40 @@ def test_kwargs_orderless():
8788
}
8889

8990

91+
def test_simple_vars():
92+
v = debug.format('test', 1, 2)
93+
s = re.sub(':\d{2,}', ':<line no>', str(v))
94+
assert s == (
95+
'tests/test_main.py:<line no> test_simple_vars\n'
96+
' "test" (str) len=4\n'
97+
' 1 (int)\n'
98+
' 2 (int)'
99+
)
100+
r = re.sub(':\d{2,}', ':<line no>', repr(v))
101+
assert r == (
102+
'<DebugOutput tests/test_main.py:<line no> test_simple_vars arguments: "test" (str) len=4 1 (int) 2 (int)>'
103+
)
104+
105+
90106
def test_eval():
91107
with pytest.warns(RuntimeWarning):
92108
v = eval('debug.format(1)')
93109

94110
assert str(v) == '<string>:1 <module>: 1 (int)'
95111

96112

113+
@pytest.mark.skipif(sys.version_info < (3, 6), reason='kwarg order is not guaranteed for 3.5')
114+
def test_eval_kwargs():
115+
with pytest.warns(RuntimeWarning):
116+
v = eval('debug.format(1, apple="pear")')
117+
118+
assert str(v) == (
119+
'<string>:1 <module>\n'
120+
' 1 (int)\n'
121+
' apple = "pear" (str) len=4'
122+
)
123+
124+
97125
def test_exec(capsys):
98126
with pytest.warns(RuntimeWarning):
99127
exec(

0 commit comments

Comments
 (0)