Skip to content

Commit f2b7f8e

Browse files
Use executing and asttokens (#82)
* Use executing and asttokens * Test `raise RuntimeError(debug.format(...))` Shows that #47 is fixed * Clean up some now unused stuff * black * Differentiate source of generator expressions between python versions * restrict version, other tweaks * tweaking is_literal * remove from requirements Co-authored-by: Samuel Colvin <[email protected]>
1 parent b498361 commit f2b7f8e

File tree

6 files changed

+101
-222
lines changed

6 files changed

+101
-222
lines changed

devtools/debug.py

Lines changed: 24 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@
44
from .ansi import sformat
55
from .prettier import PrettyFormat
66
from .timer import Timer
7-
from .utils import env_bool, env_true, use_highlight
7+
from .utils import env_bool, env_true, is_literal, use_highlight
88

99
__all__ = 'Debug', 'debug'
1010
MYPY = False
1111
if MYPY:
12-
import ast
1312
from types import FrameType
14-
from typing import Generator, List, Optional, Tuple
13+
from typing import Generator, List, Optional
1514

1615

1716
pformat = PrettyFormat(
@@ -22,10 +21,6 @@
2221
)
2322

2423

25-
class IntrospectionError(ValueError):
26-
pass
27-
28-
2924
class DebugArgument:
3025
__slots__ = 'value', 'name', 'extra'
3126

@@ -43,7 +38,7 @@ def __init__(self, value, *, name=None, **extra):
4338

4439
def str(self, highlight=False) -> str:
4540
s = ''
46-
if self.name:
41+
if self.name and not is_literal(self.name):
4742
s = sformat(self.name, sformat.blue, apply=highlight) + ': '
4843

4944
suffix = sformat(
@@ -108,21 +103,17 @@ def __repr__(self) -> str:
108103
class Debug:
109104
output_class = DebugOutput
110105

111-
def __init__(
112-
self, *, warnings: 'Optional[bool]' = None, highlight: 'Optional[bool]' = None, frame_context_length: int = 50
113-
):
106+
def __init__(self, *, warnings: 'Optional[bool]' = None, highlight: 'Optional[bool]' = None):
114107
self._show_warnings = env_bool(warnings, 'PY_DEVTOOLS_WARNINGS', True)
115108
self._highlight = highlight
116-
# 50 lines should be enough to make sure we always get the entire function definition
117-
self._frame_context_length = frame_context_length
118109

119110
def __call__(self, *args, file_=None, flush_=True, **kwargs) -> None:
120-
d_out = self._process(args, kwargs, 'debug')
111+
d_out = self._process(args, kwargs)
121112
s = d_out.str(use_highlight(self._highlight, file_))
122113
print(s, file=file_, flush=flush_)
123114

124115
def format(self, *args, **kwargs) -> DebugOutput:
125-
return self._process(args, kwargs, 'format')
116+
return self._process(args, kwargs)
126117

127118
def breakpoint(self):
128119
import pdb
@@ -132,7 +123,7 @@ def breakpoint(self):
132123
def timer(self, name=None, *, verbose=True, file=None, dp=3) -> Timer:
133124
return Timer(name=name, verbose=verbose, file=file, dp=dp)
134125

135-
def _process(self, args, kwargs, func_name: str) -> DebugOutput:
126+
def _process(self, args, kwargs) -> DebugOutput:
136127
"""
137128
BEWARE: this must be called from a function exactly 2 levels below the top of the stack.
138129
"""
@@ -165,23 +156,20 @@ def _process(self, args, kwargs, func_name: str) -> DebugOutput:
165156
lineno = call_frame.f_lineno
166157
warning = None
167158

168-
import inspect
159+
import executing
169160

170-
try:
171-
file_lines, _ = inspect.findsource(call_frame)
172-
except OSError:
161+
source = executing.Source.for_frame(call_frame)
162+
if not source.text:
173163
warning = 'no code context for debug call, code inspection impossible'
174164
arguments = list(self._args_inspection_failed(args, kwargs))
175165
else:
176-
try:
177-
first_line, last_line = self._statement_range(call_frame, func_name)
178-
func_ast, code_lines = self._parse_code(filename, file_lines, first_line, last_line)
179-
except IntrospectionError as e:
180-
# parsing failed
181-
warning = e.args[0]
166+
ex = source.executing(call_frame)
167+
# function = ex.code_qualname()
168+
if not ex.node:
169+
warning = "executing failed to find the calling node"
182170
arguments = list(self._args_inspection_failed(args, kwargs))
183171
else:
184-
arguments = list(self._process_args(func_ast, code_lines, args, kwargs))
172+
arguments = list(self._process_args(ex, args, kwargs))
185173

186174
return self.output_class(
187175
filename=filename,
@@ -197,174 +185,25 @@ def _args_inspection_failed(self, args, kwargs):
197185
for name, value in kwargs.items():
198186
yield self.output_class.arg_class(value, name=name)
199187

200-
def _process_args(self, func_ast, code_lines, args, kwargs) -> 'Generator[DebugArgument, None, None]': # noqa: C901
188+
def _process_args(self, ex, args, kwargs) -> 'Generator[DebugArgument, None, None]':
201189
import ast
202190

203-
complex_nodes = (
204-
ast.Call,
205-
ast.Attribute,
206-
ast.Subscript,
207-
ast.IfExp,
208-
ast.BoolOp,
209-
ast.BinOp,
210-
ast.Compare,
211-
ast.DictComp,
212-
ast.ListComp,
213-
ast.SetComp,
214-
ast.GeneratorExp,
215-
)
216-
217-
arg_offsets = list(self._get_offsets(func_ast))
218-
for i, arg in enumerate(args):
219-
try:
220-
ast_node = func_ast.args[i]
221-
except IndexError: # pragma: no cover
222-
# happens when code has been commented out and there are fewer func_ast args than real args
223-
yield self.output_class.arg_class(arg)
224-
continue
225-
226-
if isinstance(ast_node, ast.Name):
227-
yield self.output_class.arg_class(arg, name=ast_node.id)
228-
elif isinstance(ast_node, complex_nodes):
229-
# TODO replace this hack with astor when it get's round to a new release
230-
start_line, start_col = arg_offsets[i]
231-
232-
if i + 1 < len(arg_offsets):
233-
end_line, end_col = arg_offsets[i + 1]
234-
else:
235-
end_line, end_col = len(code_lines) - 1, None
236-
237-
name_lines = []
238-
for l_ in range(start_line, end_line + 1):
239-
start_ = start_col if l_ == start_line else 0
240-
end_ = end_col if l_ == end_line else None
241-
name_lines.append(code_lines[l_][start_:end_].strip(' '))
242-
yield self.output_class.arg_class(arg, name=' '.join(name_lines).strip(' ,'))
191+
func_ast = ex.node
192+
atok = ex.source.asttokens()
193+
for arg, ast_arg in zip(args, func_ast.args):
194+
if isinstance(ast_arg, ast.Name):
195+
yield self.output_class.arg_class(arg, name=ast_arg.id)
243196
else:
244-
yield self.output_class.arg_class(arg)
197+
name = ' '.join(map(str.strip, atok.get_text(ast_arg).splitlines()))
198+
yield self.output_class.arg_class(arg, name=name)
245199

246200
kw_arg_names = {}
247201
for kw in func_ast.keywords:
248202
if isinstance(kw.value, ast.Name):
249203
kw_arg_names[kw.arg] = kw.value.id
204+
250205
for name, value in kwargs.items():
251206
yield self.output_class.arg_class(value, name=name, variable=kw_arg_names.get(name))
252207

253-
def _parse_code(
254-
self, filename: str, file_lines: 'List[str]', first_line: int, last_line: int
255-
) -> 'Tuple[ast.AST, List[str]]':
256-
"""
257-
All we're trying to do here is build an AST of the function call statement. However numerous ugly interfaces,
258-
lack on introspection support and changes between python versions make this extremely hard.
259-
"""
260-
import ast
261-
from textwrap import dedent
262-
263-
def get_code(_last_line: int) -> str:
264-
lines = file_lines[first_line - 1 : _last_line]
265-
return dedent(''.join(ln for ln in lines if ln.strip('\n ') and not ln.lstrip(' ').startswith('#')))
266-
267-
code = get_code(last_line)
268-
func_ast = None
269-
try:
270-
func_ast = self._wrap_parse(code, filename)
271-
except (SyntaxError, AttributeError) as e1:
272-
# if the trailing bracket(s) of the function is/are on a new line e.g.:
273-
# debug(
274-
# foo, bar,
275-
# )
276-
# inspect ignores it when setting index and we have to add it back
277-
for extra in range(1, 6):
278-
code = get_code(last_line + extra)
279-
try:
280-
func_ast = self._wrap_parse(code, filename)
281-
except (SyntaxError, AttributeError):
282-
pass
283-
else:
284-
break
285-
286-
if not func_ast:
287-
raise IntrospectionError('error parsing code, {0.__class__.__name__}: {0}'.format(e1))
288-
289-
if not isinstance(func_ast, ast.Call):
290-
raise IntrospectionError('error parsing code, found {0.__class__} not Call'.format(func_ast))
291-
292-
code_lines = [line for line in code.split('\n') if line]
293-
# this removes the trailing bracket from the lines of code meaning it doesn't appear in the
294-
# representation of the last argument
295-
code_lines[-1] = code_lines[-1][:-1]
296-
return func_ast, code_lines
297-
298-
@staticmethod # noqa: C901
299-
def _statement_range(call_frame: 'FrameType', func_name: str) -> 'Tuple[int, int]': # noqa: C901
300-
"""
301-
Try to find the start and end of a frame statement.
302-
"""
303-
import dis
304-
305-
# dis.disassemble(call_frame.f_code, call_frame.f_lasti)
306-
# pprint([i for i in dis.get_instructions(call_frame.f_code)])
307-
308-
instructions = iter(dis.get_instructions(call_frame.f_code))
309-
first_line = None
310-
last_line = None
311-
312-
for instr in instructions: # pragma: no branch
313-
if (
314-
instr.starts_line
315-
and instr.opname in {'LOAD_GLOBAL', 'LOAD_NAME'}
316-
and (instr.argval == func_name or (instr.argval == 'debug' and next(instructions).argval == func_name))
317-
):
318-
first_line = instr.starts_line
319-
if instr.offset == call_frame.f_lasti:
320-
break
321-
322-
if first_line is None:
323-
raise IntrospectionError('error parsing code, unable to find "{}" function statement'.format(func_name))
324-
325-
for instr in instructions:
326-
if instr.starts_line:
327-
last_line = instr.starts_line - 1
328-
break
329-
330-
if last_line is None:
331-
if sys.version_info >= (3, 8):
332-
# absolutely no reliable way of getting the last line of the statement, complete hack is to
333-
# get the last line of the last statement of the whole code block and go from there
334-
# this assumes (perhaps wrongly?) that the reason we couldn't find last_line is that the statement
335-
# in question was the last of the block
336-
last_line = max(i.starts_line for i in dis.get_instructions(call_frame.f_code) if i.starts_line)
337-
else:
338-
# in older version of python f_lineno is the end of the statement, not the beginning
339-
# so this is a reasonable guess
340-
last_line = call_frame.f_lineno
341-
342-
return first_line, last_line
343-
344-
@staticmethod
345-
def _wrap_parse(code: str, filename: str) -> 'ast.Call':
346-
"""
347-
async wrapper is required to avoid await calls raising a SyntaxError
348-
"""
349-
import ast
350-
from textwrap import indent
351-
352-
code = 'async def wrapper():\n' + indent(code, ' ')
353-
return ast.parse(code, filename=filename).body[0].body[0].value
354-
355-
@staticmethod
356-
def _get_offsets(func_ast):
357-
import ast
358-
359-
for arg in func_ast.args:
360-
start_line, start_col = arg.lineno - 2, arg.col_offset - 1
361-
362-
# horrible hack for http://bugs.python.org/issue31241
363-
if isinstance(arg, (ast.ListComp, ast.GeneratorExp)):
364-
start_col -= 1
365-
yield start_line, start_col
366-
for kw in func_ast.keywords:
367-
yield kw.value.lineno - 2, kw.value.col_offset - 2 - (len(kw.arg) if kw.arg else 0)
368-
369208

370209
debug = Debug()

devtools/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,14 @@ def use_highlight(highlight: 'Optional[bool]' = None, file_=None) -> bool:
9595
if sys.platform == 'win32': # pragma: no cover
9696
return isatty(file_) and activate_win_color()
9797
return isatty(file_)
98+
99+
100+
def is_literal(s):
101+
import ast
102+
103+
try:
104+
ast.literal_eval(s)
105+
except (TypeError, MemoryError, SyntaxError, ValueError):
106+
return False
107+
else:
108+
return True

setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@
4848
license='MIT',
4949
packages=['devtools'],
5050
python_requires='>=3.6',
51+
install_requires=[
52+
'executing>=0.8.0,<1.0.0',
53+
'asttokens>=2.0.0,<3.0.0',
54+
],
5155
extras_require={
5256
'pygments': ['Pygments>=2.2.0'],
5357
},

0 commit comments

Comments
 (0)