1
1
import ast
2
+ import dis
2
3
import inspect
3
4
import os
4
5
import pdb
5
- import re
6
+ import sys
6
7
from pathlib import Path
7
8
from textwrap import dedent , indent
9
+ from types import FrameType
8
10
from typing import Generator , List , Optional , Tuple
9
11
10
12
from .ansi import isatty , sformat
23
25
)
24
26
25
27
28
+ class IntrospectionError (ValueError ):
29
+ pass
30
+
31
+
26
32
class DebugArgument :
27
33
__slots__ = 'value' , 'name' , 'extra'
28
34
@@ -134,38 +140,39 @@ def _env_bool(cls, value, env_name, env_default):
134
140
return value
135
141
136
142
def __call__ (self , * args , file_ = None , flush_ = True , ** kwargs ) -> None :
137
- d_out = self ._process (args , kwargs , r 'debug *\( ' )
143
+ d_out = self ._process (args , kwargs , 'debug' )
138
144
highlight = isatty (file_ ) if self ._highlight is None else self ._highlight
139
145
s = d_out .str (highlight )
140
146
print (s , file = file_ , flush = flush_ )
141
147
142
148
def format (self , * args , ** kwargs ) -> DebugOutput :
143
- return self ._process (args , kwargs , r'debug. format *\( ' )
149
+ return self ._process (args , kwargs , ' format' )
144
150
145
151
def breakpoint (self ):
146
152
pdb .Pdb (skip = ['devtools.*' ]).set_trace ()
147
153
148
154
def timer (self , name = None , * , verbose = True , file = None , dp = 3 ) -> Timer :
149
155
return Timer (name = name , verbose = verbose , file = file , dp = dp )
150
156
151
- def _process (self , args , kwargs , func_regex ) -> DebugOutput :
152
- curframe = inspect .currentframe ()
157
+ def _process (self , args , kwargs , func_name : str ) -> DebugOutput :
158
+ """
159
+ BEWARE: this must be called from a function exactly 2 levels below the top of the stack.
160
+ """
161
+ # HELP: any errors other than ValueError from _getframe? If so please submit an issue
153
162
try :
154
- frames = inspect .getouterframes (curframe , context = self ._frame_context_length )
155
- except IndexError :
156
- # NOTICE: we should really catch all conceivable errors here, if you find one please report.
157
- # IndexError happens in odd situations such as code called from within jinja templates
163
+ call_frame : FrameType = sys ._getframe (2 )
164
+ except ValueError :
165
+ # "If [ValueError] is deeper than the call stack, ValueError is raised"
158
166
return self .output_class (
159
167
filename = '<unknown>' ,
160
168
lineno = 0 ,
161
169
frame = '' ,
162
170
arguments = list (self ._args_inspection_failed (args , kwargs )),
163
- warning = self ._show_warnings and 'error parsing code, IndexError ' ,
171
+ warning = self ._show_warnings and 'error parsing code, call stack too shallow ' ,
164
172
)
165
- # BEWARE: this must be called by a method which in turn is called "directly" for the frame to be correct
166
- call_frame = frames [2 ]
167
173
168
- filename = call_frame .filename
174
+ filename = call_frame .f_code .co_filename
175
+ function = call_frame .f_code .co_name
169
176
if filename .startswith ('/' ):
170
177
# make the path relative
171
178
try :
@@ -174,22 +181,29 @@ def _process(self, args, kwargs, func_regex) -> DebugOutput:
174
181
# happens if filename path is not within CWD
175
182
pass
176
183
177
- if call_frame .code_context :
178
- func_ast , code_lines , lineno , warning = self ._parse_code (call_frame , func_regex , filename )
179
- if func_ast :
180
- arguments = list (self ._process_args (func_ast , code_lines , args , kwargs ))
181
- else :
182
- # parsing failed
183
- arguments = list (self ._args_inspection_failed (args , kwargs ))
184
- else :
185
- lineno = call_frame .lineno
184
+ lineno = call_frame .f_lineno
185
+ warning = None
186
+
187
+ try :
188
+ file_lines , _ = inspect .findsource (call_frame )
189
+ except OSError :
186
190
warning = 'no code context for debug call, code inspection impossible'
187
191
arguments = list (self ._args_inspection_failed (args , kwargs ))
192
+ else :
193
+ try :
194
+ first_line , last_line = self ._statement_range (call_frame , func_name )
195
+ func_ast , code_lines = self ._parse_code (filename , file_lines , first_line , last_line )
196
+ except IntrospectionError as e :
197
+ # parsing failed
198
+ warning = e .args [0 ]
199
+ arguments = list (self ._args_inspection_failed (args , kwargs ))
200
+ else :
201
+ arguments = list (self ._process_args (func_ast , code_lines , args , kwargs ))
188
202
189
203
return self .output_class (
190
204
filename = filename ,
191
205
lineno = lineno ,
192
- frame = call_frame . function ,
206
+ frame = function ,
193
207
arguments = arguments ,
194
208
warning = self ._show_warnings and warning ,
195
209
)
@@ -238,34 +252,29 @@ def _process_args(self, func_ast, code_lines, args, kwargs) -> Generator[DebugAr
238
252
yield self .output_class .arg_class (value , name = name , variable = kw_arg_names .get (name ))
239
253
240
254
def _parse_code (
241
- self , call_frame , func_regex , filename
242
- ) -> Tuple [Optional [ast .AST ], Optional [List [str ]], int , Optional [str ]]:
243
- call_lines = []
244
- for line in range (call_frame .index , - 1 , - 1 ):
245
- try :
246
- new_line = call_frame .code_context [line ]
247
- except IndexError : # pragma: no cover
248
- return None , None , line , 'error parsing code. line not found'
249
- call_lines .append (new_line )
250
- if re .search (func_regex , new_line ):
251
- break
252
- call_lines .reverse ()
253
- lineno = call_frame .lineno - len (call_lines ) + 1
255
+ self , filename : str , file_lines : List [str ], first_line : int , last_line : int
256
+ ) -> Tuple [ast .AST , List [str ]]:
257
+ """
258
+ All we're trying to do here is build an AST of the function call statement. However numerous ugly interfaces,
259
+ lack on introspection support and changes between python versions make this extremely hard.
260
+ """
254
261
255
- code = dedent ('' .join (call_lines ))
262
+ def get_code (_last_line : int ) -> str :
263
+ lines = file_lines [first_line - 1 : _last_line ]
264
+ return dedent ('' .join (ln for ln in lines if ln .strip ('\n ' ) and not ln .lstrip (' ' ).startswith ('#' )))
265
+
266
+ code = get_code (last_line )
256
267
func_ast = None
257
- tail_index = call_frame .index
258
268
try :
259
269
func_ast = self ._wrap_parse (code , filename )
260
270
except (SyntaxError , AttributeError ) as e1 :
261
- # if the trailing bracket(s) of the function is/are on a new line eg.
271
+ # if the trailing bracket(s) of the function is/are on a new line e.g.:
262
272
# debug(
263
273
# foo, bar,
264
274
# )
265
275
# inspect ignores it when setting index and we have to add it back
266
- for extra in range (2 , 6 ):
267
- extra_lines = call_frame .code_context [tail_index + 1 : tail_index + extra ]
268
- code = dedent ('' .join (call_lines + extra_lines ))
276
+ for extra in range (1 , 6 ):
277
+ code = get_code (last_line + extra )
269
278
try :
270
279
func_ast = self ._wrap_parse (code , filename )
271
280
except (SyntaxError , AttributeError ):
@@ -274,16 +283,64 @@ def _parse_code(
274
283
break
275
284
276
285
if not func_ast :
277
- return None , None , lineno , 'error parsing code, {0.__class__.__name__}: {0}' .format (e1 )
286
+ raise IntrospectionError ( 'error parsing code, {0.__class__.__name__}: {0}' .format (e1 ) )
278
287
279
288
if not isinstance (func_ast , ast .Call ):
280
- return None , None , lineno , 'error parsing code, found {} not Call' .format (func_ast . __class__ )
289
+ raise IntrospectionError ( 'error parsing code, found {0.__class__ } not Call' .format (func_ast ) )
281
290
282
291
code_lines = [line for line in code .split ('\n ' ) if line ]
283
292
# this removes the trailing bracket from the lines of code meaning it doesn't appear in the
284
293
# representation of the last argument
285
294
code_lines [- 1 ] = code_lines [- 1 ][:- 1 ]
286
- return func_ast , code_lines , lineno , None
295
+ return func_ast , code_lines
296
+
297
+ @staticmethod # noqa: C901
298
+ def _statement_range (call_frame : FrameType , func_name : str ) -> Tuple [int , int ]: # noqa: C901
299
+ """
300
+ Try to find the start and end of a frame statement.
301
+ """
302
+ # dis.disassemble(call_frame.f_code, call_frame.f_lasti)
303
+ # pprint([i for i in dis.get_instructions(call_frame.f_code)])
304
+
305
+ instructions = iter (dis .get_instructions (call_frame .f_code ))
306
+ first_line = None
307
+ last_line = None
308
+
309
+ for instr in instructions :
310
+ if instr .starts_line :
311
+ if instr .opname in {'LOAD_GLOBAL' , 'LOAD_NAME' } and instr .argval == func_name :
312
+ first_line = instr .starts_line
313
+ break
314
+ elif instr .opname == 'LOAD_GLOBAL' and instr .argval == 'debug' :
315
+ if next (instructions ).argval == func_name :
316
+ first_line = instr .starts_line
317
+ break
318
+
319
+ if first_line is None :
320
+ raise IntrospectionError ('error parsing code, unable to find "{}" function statement' .format (func_name ))
321
+
322
+ for instr in instructions : # pragma: no branch
323
+ if instr .offset == call_frame .f_lasti :
324
+ break
325
+
326
+ for instr in instructions :
327
+ if instr .starts_line :
328
+ last_line = instr .starts_line - 1
329
+ break
330
+
331
+ if last_line is None :
332
+ if sys .version_info >= (3 , 8 ):
333
+ # absolutely no reliable way of getting the last line of the statement, complete hack is to
334
+ # get the last line of the last statement of the whole code block and go from there
335
+ # this assumes (perhaps wrongly?) that the reason we couldn't find last_line is that the statement
336
+ # in question was the last of the block
337
+ last_line = max (i .starts_line for i in dis .get_instructions (call_frame .f_code ) if i .starts_line )
338
+ else :
339
+ # in older version of python f_lineno is the end of the statement, not the beginning
340
+ # so this is a reasonable guess
341
+ last_line = call_frame .f_lineno
342
+
343
+ return first_line , last_line
287
344
288
345
@staticmethod
289
346
def _wrap_parse (code , filename ):
0 commit comments