diff --git a/devtools/debug.py b/devtools/debug.py index 89cda24..f041333 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -66,43 +66,37 @@ class DebugOutput: """ arg_class = DebugArgument - __slots__ = 'filename', 'lineno', 'frame', 'arguments', 'warning' + __slots__ = 'call_context', 'arguments', 'warning' def __init__( self, *, - filename: str, - lineno: int, - frame: str, + call_context: 'List[DebugFrame]', arguments: 'List[DebugArgument]', warning: 'Union[None, str, bool]' = None, ) -> None: - self.filename = filename - self.lineno = lineno - self.frame = frame + self.call_context = call_context self.arguments = arguments self.warning = warning def str(self, highlight: bool = False) -> StrType: - if highlight: - prefix = ( - f'{sformat(self.filename, sformat.magenta)}:{sformat(self.lineno, sformat.green)} ' - f'{sformat(self.frame, sformat.green, sformat.italic)}' - ) - if self.warning: + prefix = '\n'.join(x.str(highlight) for x in self.call_context) + + if self.warning: + if highlight: prefix += sformat(f' ({self.warning})', sformat.dim) - else: - prefix = f'{self.filename}:{self.lineno} {self.frame}' - if self.warning: + else: prefix += f' ({self.warning})' + return f'{prefix}\n ' + '\n '.join(a.str(highlight) for a in self.arguments) def __str__(self) -> StrType: return self.str() def __repr__(self) -> StrType: + context = self.call_context[-1] arguments = ' '.join(str(a) for a in self.arguments) - return f'' + return f'' class Debug: @@ -118,9 +112,10 @@ def __call__( file_: 'Any' = None, flush_: bool = True, frame_depth_: int = 2, + trace_: bool = False, **kwargs: 'Any', ) -> 'Any': - d_out = self._process(args, kwargs, frame_depth_) + d_out = self._process(args, kwargs, frame_depth_, trace_) s = d_out.str(use_highlight(self._highlight, file_)) print(s, file=file_, flush=flush_) if kwargs: @@ -130,8 +125,25 @@ def __call__( else: return args - def format(self, *args: 'Any', frame_depth_: int = 2, **kwargs: 'Any') -> DebugOutput: - return self._process(args, kwargs, frame_depth_) + def trace( + self, + *args: 'Any', + file_: 'Any' = None, + flush_: bool = True, + frame_depth_: int = 2, + **kwargs: 'Any', + ) -> 'Any': + return self.__call__( + *args, + file_=file_, + flush_=flush_, + frame_depth_=frame_depth_ + 1, + trace_=True, + **kwargs, + ) + + def format(self, *args: 'Any', frame_depth_: int = 2, trace_: bool = False, **kwargs: 'Any') -> DebugOutput: + return self._process(args, kwargs, frame_depth_, trace_) def breakpoint(self) -> None: import pdb @@ -141,7 +153,7 @@ def breakpoint(self) -> None: def timer(self, name: 'Optional[str]' = None, *, verbose: bool = True, file: 'Any' = None, dp: int = 3) -> Timer: return Timer(name=name, verbose=verbose, file=file, dp=dp) - def _process(self, args: 'Any', kwargs: 'Any', frame_depth: int) -> DebugOutput: + def _process(self, args: 'Any', kwargs: 'Any', frame_depth: int, trace: bool) -> DebugOutput: """ BEWARE: this must be called from a function exactly `frame_depth` levels below the top of the stack. """ @@ -151,28 +163,13 @@ def _process(self, args: 'Any', kwargs: 'Any', frame_depth: int) -> DebugOutput: except ValueError: # "If [ValueError] is deeper than the call stack, ValueError is raised" return self.output_class( - filename='', - lineno=0, - frame='', + call_context=[DebugFrame(function='', path='', lineno=0)], arguments=list(self._args_inspection_failed(args, kwargs)), warning=self._show_warnings and 'error parsing code, call stack too shallow', ) - function = call_frame.f_code.co_name - - from pathlib import Path - - path = Path(call_frame.f_code.co_filename) - if path.is_absolute(): - # make the path relative - cwd = Path('.').resolve() - try: - path = path.relative_to(cwd) - except ValueError: - # happens if filename path is not within CWD - pass + call_context = _make_call_context(call_frame, trace) - lineno = call_frame.f_lineno warning = None import executing @@ -183,7 +180,7 @@ def _process(self, args: 'Any', kwargs: 'Any', frame_depth: int) -> DebugOutput: arguments = list(self._args_inspection_failed(args, kwargs)) else: ex = source.executing(call_frame) - function = ex.code_qualname() + call_context[-1].function = ex.code_qualname() if not ex.node: warning = 'executing failed to find the calling node' arguments = list(self._args_inspection_failed(args, kwargs)) @@ -191,9 +188,7 @@ def _process(self, args: 'Any', kwargs: 'Any', frame_depth: int) -> DebugOutput: arguments = list(self._process_args(ex, args, kwargs)) return self.output_class( - filename=str(path), - lineno=lineno, - frame=function, + call_context=call_context, arguments=arguments, warning=self._show_warnings and warning, ) @@ -225,4 +220,59 @@ def _process_args(self, ex: 'Any', args: 'Any', kwargs: 'Any') -> 'Generator[Deb yield self.output_class.arg_class(value, name=name, variable=kw_arg_names.get(name)) +class DebugFrame: + __slots__ = 'function', 'path', 'lineno' + + def __init__(self, function: str, path: str, lineno: int): + self.function = function + self.path = path + self.lineno = lineno + + @staticmethod + def from_call_frame(call_frame: 'FrameType') -> 'DebugFrame': + from pathlib import Path + + function = call_frame.f_code.co_name + + path = Path(call_frame.f_code.co_filename) + if path.is_absolute(): + # make the path relative + cwd = Path('.').resolve() + try: + path = path.relative_to(cwd) + except ValueError: + # happens if filename path is not within CWD + pass + + lineno = call_frame.f_lineno + + return DebugFrame(function, str(path), lineno) + + def __str__(self) -> StrType: + return self.str() + + def str(self, highlight: bool = False) -> StrType: + if highlight: + return ( + f'{sformat(self.path, sformat.magenta)}:{sformat(self.lineno, sformat.green)} ' + f'{sformat(self.function, sformat.green, sformat.italic)}' + ) + else: + return f'{self.path}:{self.lineno} {self.function}' + + +def _make_call_context(call_frame: 'Optional[FrameType]', trace: bool) -> 'List[DebugFrame]': + call_context: 'List[DebugFrame]' = [] + + while call_frame: + frame_info = DebugFrame.from_call_frame(call_frame) + call_context.insert(0, frame_info) + call_frame = call_frame.f_back + + if not trace: + break + + return call_context + + debug = Debug() diff --git a/tests/test_main.py b/tests/test_main.py index 1057313..acf9a2a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -60,27 +60,11 @@ def test_print_generator(capsys): assert list(result) == [] -def test_format(): - a = b'i might bite' - b = 'hello this is a test' - v = debug.format(a, b) - s = normalise_output(str(v)) - print(s) - assert s == ( - "tests/test_main.py: test_format\n" - " a: b'i might bite' (bytes) len=12\n" - " b: 'hello this is a test' (str) len=20" - ) - - -@pytest.mark.xfail( - sys.platform == 'win32', - reason='Fatal Python error: _Py_HashRandomization_Init: failed to get random numbers to initialize Python', -) -def test_print_subprocess(tmpdir): - f = tmpdir.join('test.py') - f.write( - """\ +@pytest.mark.parametrize( + ['py_script', 'stdout'], + [ + ( + """\ from devtools import debug def test_func(v): @@ -91,20 +75,131 @@ def test_func(v): debug(foobar) test_func(42) print('debug run.') - """ +""", + """\ +running debug... +/path/to/test.py:8 + foobar: 'hello world' (str) len=11 +/path/to/test.py:4 test_func + 'in test func' (str) len=12 + v: 42 (int) +debug run. +""", + ), + ( + """\ +from devtools import debug + +def f(x): + debug(x, trace_=True) + g(x) + +def g(x): + debug(x, trace_=True) + +x = 42 +debug(x, trace_=True) +f(x) +""", + """\ +/path/to/test.py:11 + x: 42 (int) +/path/to/test.py:12 +/path/to/test.py:4 f + x: 42 (int) +/path/to/test.py:12 +/path/to/test.py:5 f +/path/to/test.py:8 g + x: 42 (int) +""", + ), + ( + """\ +from devtools import debug + +def f(x): + print(debug.format(x, trace_=True)) + g(x) + +def g(x): + print(debug.format(x, trace_=True)) + +x = 42 +print(debug.format(x, trace_=True)) +f(x) +""", + """\ +/path/to/test.py:11 + x: 42 (int) +/path/to/test.py:12 +/path/to/test.py:4 f + x: 42 (int) +/path/to/test.py:12 +/path/to/test.py:5 f +/path/to/test.py:8 g + x: 42 (int) +""", + ), + ( + """\ +from devtools import debug + +def f(x): + debug.trace(x) + g(x) + +def g(x): + debug.trace(x) + +x = 42 +debug.trace(x) +f(x) +""", + """\ +/path/to/test.py:11 + x: 42 (int) +/path/to/test.py:12 +/path/to/test.py:4 f + x: 42 (int) +/path/to/test.py:12 +/path/to/test.py:5 f +/path/to/test.py:8 g + x: 42 (int) +""", + ), + ], +) +@pytest.mark.xfail( + sys.platform == 'win32', + reason='Fatal Python error: _Py_HashRandomization_Init: failed to get random numbers to initialize Python', +) +def test_print_subprocess(py_script, stdout, tmp_path): + f = tmp_path / 'test.py' + f.write_text(py_script) + + p = run( + [sys.executable, str(f)], + capture_output=True, + text=True, + env={ + 'PYTHONPATH': str(Path(__file__).parents[1].resolve()), + }, ) - env = {'PYTHONPATH': str(Path(__file__).parent.parent.resolve())} - p = run([sys.executable, str(f)], capture_output=True, text=True, env=env) assert p.stderr == '' assert p.returncode == 0, (p.stderr, p.stdout) - assert p.stdout.replace(str(f), '/path/to/test.py') == ( - "running debug...\n" - "/path/to/test.py:8 \n" - " foobar: 'hello world' (str) len=11\n" - "/path/to/test.py:4 test_func\n" - " 'in test func' (str) len=12\n" - " v: 42 (int)\n" - "debug run.\n" + assert p.stdout.replace(str(f), '/path/to/test.py') == stdout + + +def test_format(): + a = b'i might bite' + b = 'hello this is a test' + v = debug.format(a, b) + s = normalise_output(str(v)) + print(s) + assert s == ( + "tests/test_main.py: test_format\n" + " a: b'i might bite' (bytes) len=12\n" + " b: 'hello this is a test' (str) len=20" )