Skip to content

Commit 2dffbe1

Browse files
committed
better expression processing and tests
1 parent 295368e commit 2dffbe1

File tree

3 files changed

+208
-24
lines changed

3 files changed

+208
-24
lines changed

devtools/debug.py

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ def __repr__(self) -> str:
6464

6565
class Debug:
6666
output_class = DebugOutput
67+
# 50 lines should be enough to make sure we always get the entire function definition
68+
frame_context_length = 50
6769

6870
def __call__(self, *args, **kwargs):
6971
print(self._process(args, kwargs, r'debug *\('), flush=True)
@@ -73,7 +75,7 @@ def format(self, *args, **kwargs):
7375

7476
def _process(self, args, kwargs, func_regex):
7577
curframe = inspect.currentframe()
76-
frames = inspect.getouterframes(curframe, context=20)
78+
frames = inspect.getouterframes(curframe, context=self.frame_context_length)
7779
# BEWARE: this must be call by a method which in turn is called "directly" for the frame to be correct
7880
call_frame = frames[2]
7981

@@ -87,24 +89,63 @@ def _process(self, args, kwargs, func_regex):
8789
pass
8890

8991
call_lines = []
90-
for line in range(call_frame.index, 0, -1):
91-
new_line = call_frame.code_context[line]
92-
call_lines.append(new_line)
93-
if re.search(func_regex, new_line):
94-
break
95-
call_lines.reverse()
92+
# print(call_frame)
93+
# from pprint import pprint
94+
# pprint(call_frame.code_context)
95+
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
103+
else:
104+
lineno = call_frame.lineno - len(call_lines)
96105

97106
return self.output_class(
98107
filename=filename,
99-
lineno=call_frame.lineno - len(call_lines) + 1,
108+
lineno=lineno,
100109
frame=call_frame.function,
101-
arguments=list(self._process_args(call_lines, args, kwargs))
110+
arguments=list(self._process_args(call_lines, args, kwargs, call_frame))
102111
)
103112

104-
def _process_args(self, call_lines, args, kwargs) -> Generator[DebugArgument, None, None]: # noqa: C901
113+
def _args_inspection_failed(self, args, kwargs):
114+
for arg in args:
115+
yield self.output_class.arg_class(arg)
116+
for name, value in kwargs.items():
117+
yield self.output_class.arg_class(value, name=name)
118+
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+
105125
code = dedent(''.join(call_lines))
106126
# print(code)
107-
func_ast = ast.parse(code).body[0].value
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]
108149

109150
arg_offsets = list(self._get_offsets(func_ast))
110151
for arg, ast_node, i in zip(args, func_ast.args, range(1000)):
@@ -114,17 +155,22 @@ def _process_args(self, call_lines, args, kwargs) -> Generator[DebugArgument, No
114155
yield self.output_class.arg_class(arg)
115156
elif isinstance(ast_node, (ast.Call, ast.Compare)):
116157
# TODO replace this hack with astor when it get's round to a new release
117-
end = -2
118-
try:
119-
next_line, next_offset = arg_offsets[i + 1]
120-
if next_line == ast_node.lineno:
121-
end = next_offset
122-
except IndexError:
123-
pass
124-
name = call_lines[ast_node.lineno - 1][ast_node.col_offset:end]
125-
yield self.output_class.arg_class(arg, name=name.strip(' ,'))
158+
start_line, start_col = ast_node.lineno - 1, ast_node.col_offset
159+
end_line, end_col = len(code_lines) - 1, None
160+
161+
if i < len(arg_offsets) - 1:
162+
end_line, end_col = arg_offsets[i + 1]
163+
164+
name_lines = []
165+
for l in range(start_line, end_line + 1):
166+
start_ = start_col if l == start_line else 0
167+
end_ = end_col if l == end_line else None
168+
name_lines.append(
169+
code_lines[l][start_:end_].strip(' ')
170+
)
171+
yield self.output_class.arg_class(arg, name=' '.join(name_lines).strip(' ,'))
126172
else:
127-
warnings.warn('Unknown type: {}'.format(ast.dump(ast_node)), category=RuntimeError)
173+
warnings.warn('Unknown type: {}'.format(ast.dump(ast_node)), RuntimeWarning)
128174
yield self.output_class.arg_class(arg)
129175

130176
kw_arg_names = {}
@@ -137,9 +183,9 @@ def _process_args(self, call_lines, args, kwargs) -> Generator[DebugArgument, No
137183
@classmethod
138184
def _get_offsets(cls, func_ast):
139185
for arg in func_ast.args:
140-
yield arg.lineno, arg.col_offset
186+
yield arg.lineno - 1, arg.col_offset
141187
for kw in func_ast.keywords:
142-
yield kw.value.lineno, kw.value.col_offset - len(kw.arg) - 1
188+
yield kw.value.lineno - 1, kw.value.col_offset - len(kw.arg) - 1
143189

144190

145191
debug = Debug()

tests/test_expr_render.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import re
2+
import sys
3+
4+
import pytest
5+
6+
from devtools import debug
7+
8+
9+
def foobar(a, b, c):
10+
return a + b + c
11+
12+
13+
def test_simple():
14+
a = [1, 2, 3]
15+
v = debug.format(len(a))
16+
s = re.sub(':\d{2,}', ':<line no>', str(v))
17+
# print(s)
18+
assert s == (
19+
'tests/test_expr_render.py:<line no> test_simple: len(a) = 3 (int)'
20+
)
21+
22+
23+
def test_newline():
24+
v = debug.format(
25+
foobar(1, 2, 3))
26+
s = re.sub(':\d{2,}', ':<line no>', str(v))
27+
# print(s)
28+
assert s == (
29+
'tests/test_expr_render.py:<line no> test_newline: foobar(1, 2, 3) = 6 (int)'
30+
)
31+
32+
33+
def test_trailing_bracket():
34+
v = debug.format(
35+
foobar(1, 2, 3)
36+
)
37+
s = re.sub(':\d{2,}', ':<line no>', str(v))
38+
# print(s)
39+
assert s == (
40+
'tests/test_expr_render.py:<line no> test_trailing_bracket: foobar(1, 2, 3) = 6 (int)'
41+
)
42+
43+
44+
def test_multiline():
45+
v = debug.format(
46+
foobar(1,
47+
2,
48+
3)
49+
)
50+
s = re.sub(':\d{2,}', ':<line no>', str(v))
51+
# print(s)
52+
assert s == (
53+
'tests/test_expr_render.py:<line no> test_multiline: foobar(1, 2, 3) = 6 (int)'
54+
)
55+
56+
57+
def test_multiline_trailing_bracket():
58+
v = debug.format(
59+
foobar(1, 2, 3
60+
))
61+
s = re.sub(':\d{2,}', ':<line no>', str(v))
62+
# print(s)
63+
assert s == (
64+
'tests/test_expr_render.py:<line no> test_multiline_trailing_bracket: foobar(1, 2, 3 ) = 6 (int)'
65+
)
66+
67+
68+
@pytest.mark.skipif(sys.version_info < (3, 6), reason='kwarg order is not guaranteed for 3.5')
69+
def test_kwargs():
70+
v = debug.format(
71+
foobar(1, 2, 3),
72+
a=6,
73+
b=7
74+
)
75+
s = re.sub(':\d{2,}', ':<line no>', str(v))
76+
assert s == (
77+
'tests/test_expr_render.py:<line no> test_kwargs\n'
78+
' foobar(1, 2, 3) = 6 (int)\n'
79+
' a = 6 (int)\n'
80+
' b = 7 (int)'
81+
82+
)
83+
84+
85+
@pytest.mark.skipif(sys.version_info < (3, 6), reason='kwarg order is not guaranteed for 3.5')
86+
def test_kwargs_multiline():
87+
v = debug.format(
88+
foobar(1, 2,
89+
3),
90+
a=6,
91+
b=7
92+
)
93+
s = re.sub(':\d{2,}', ':<line no>', str(v))
94+
assert s == (
95+
'tests/test_expr_render.py:<line no> test_kwargs_multiline\n'
96+
' foobar(1, 2, 3) = 6 (int)\n'
97+
' a = 6 (int)\n'
98+
' b = 7 (int)'
99+
100+
)

tests/test_main.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from pathlib import Path
44
from subprocess import PIPE, run
55

6+
import pytest
7+
68
from devtools import debug
79

810

@@ -61,13 +63,49 @@ def test_func(v):
6163
)
6264

6365

66+
@pytest.mark.skipif(sys.version_info < (3, 6), reason='kwarg order is not guaranteed for 3.5')
6467
def test_kwargs():
6568
a = 'variable'
6669
v = debug.format(first=a, second='literal')
6770
s = re.sub(':\d{2,}', ':<line no>', str(v))
68-
# print(s)
71+
print(s)
6972
assert s == (
7073
'tests/test_main.py:<line no> test_kwargs\n'
7174
' first = "variable" (str) len=8 variable=a\n'
7275
' second = "literal" (str) len=7'
7376
)
77+
78+
79+
def test_kwargs_orderless():
80+
a = 'variable'
81+
v = debug.format(first=a, second='literal')
82+
s = re.sub(':\d{2,}', ':<line no>', str(v))
83+
assert set(s.split('\n')) == {
84+
'tests/test_main.py:<line no> test_kwargs_orderless',
85+
' first = "variable" (str) len=8 variable=a',
86+
' second = "literal" (str) len=7',
87+
}
88+
89+
90+
def test_eval():
91+
with pytest.warns(RuntimeWarning):
92+
v = eval('debug.format(1)')
93+
94+
assert str(v) == '<string>:1 <module>: 1 (int)'
95+
96+
97+
def test_exec(capsys):
98+
with pytest.warns(RuntimeWarning):
99+
exec(
100+
'a = 1\n'
101+
'b = 2\n'
102+
'debug(b, a + b)'
103+
)
104+
105+
stdout, stderr = capsys.readouterr()
106+
assert stdout == (
107+
'<string>:3 <module>\n'
108+
' 2 (int)\n'
109+
' 3 (int)\n'
110+
)
111+
assert stderr == ''

0 commit comments

Comments
 (0)