Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to include stack trace in debug() output #143

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 92 additions & 42 deletions devtools/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<DebugOutput {self.filename}:{self.lineno} {self.frame} arguments: {arguments}>'
return f'<DebugOutput {context.path}:{context.lineno} {context.function} arguments: {arguments}>'


class Debug:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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.
"""
Expand All @@ -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='<unknown>',
lineno=0,
frame='',
call_context=[DebugFrame(function='', path='<unknown>', 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
Expand All @@ -183,17 +180,15 @@ 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))
else:
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,
)
Expand Down Expand Up @@ -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]':
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might make most sense for this to be part of DebugFrame

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's not really much difference between a function and a static method, but all else being equal, I prefer functions. I think they're easier to understand because you know they only have access to the public APIs of any classes they use. But I can make it a method if you care strongly about it.

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()
159 changes: 127 additions & 32 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<line no> 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):
Expand All @@ -91,20 +75,131 @@ def test_func(v):
debug(foobar)
test_func(42)
print('debug run.')
"""
""",
"""\
running debug...
/path/to/test.py:8 <module>
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 <module>
x: 42 (int)
/path/to/test.py:12 <module>
/path/to/test.py:4 f
x: 42 (int)
/path/to/test.py:12 <module>
/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 <module>
x: 42 (int)
/path/to/test.py:12 <module>
/path/to/test.py:4 f
x: 42 (int)
/path/to/test.py:12 <module>
/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 <module>
x: 42 (int)
/path/to/test.py:12 <module>
/path/to/test.py:4 f
x: 42 (int)
/path/to/test.py:12 <module>
/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 <module>\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:<line no> test_format\n"
" a: b'i might bite' (bytes) len=12\n"
" b: 'hello this is a test' (str) len=20"
)


Expand Down