Skip to content

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

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
139 changes: 95 additions & 44 deletions devtools/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,47 @@
StrType = str


class DebugFrame:
Copy link
Owner

Choose a reason for hiding this comment

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

probably best to put this in a new module, otherwise it should go at the bottom of debug.py.

Copy link
Author

Choose a reason for hiding this comment

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

I think this class is pretty tightly-coupled to all the other classes in debug.py, so I just moved it to the bottom.

__slots__ = 'function', 'path', '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 __init__(self, function: str, path: str, lineno: int):
Copy link
Owner

Choose a reason for hiding this comment

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

__init__ should come first.

Copy link
Author

Choose a reason for hiding this comment

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

Done.

self.function = function
self.path = path
self.lineno = 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}'


class DebugArgument:
__slots__ = 'value', 'name', 'extra'

Expand Down Expand Up @@ -66,43 +107,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)

return prefix + '\n ' + '\n '.join(a.str(highlight) for a in self.arguments)
Copy link
Owner

Choose a reason for hiding this comment

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

I don't think this needs to change.

Copy link
Author

Choose a reason for hiding this comment

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

Thought it would be more efficient to avoid forcing python to evaluate the f-string, but not important.


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 +153,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 +166,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,38 +194,24 @@ 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.
"""
# HELP: any errors other than ValueError from _getframe? If so please submit an issue
try:
call_frame: 'FrameType' = sys._getframe(frame_depth)
except ValueError:
# "If [ValueError] is deeper than the call stack, ValueError is raised"
# "If [the given frame depth] is deeper than the call stack,
Copy link
Owner

Choose a reason for hiding this comment

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

undo this change.

Copy link
Author

Choose a reason for hiding this comment

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

Happy to undo this, but this comment made no sense to me. I had to go find that quote in the actual python docs to figure out what it meant.

# 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 +222,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 +262,18 @@ 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))


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()
2 changes: 2 additions & 0 deletions requirements/testing.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ coverage[toml]
pytest
pytest-mock
pytest-pretty
pytest-tmp-files
parametrize-from-file
Copy link
Owner

Choose a reason for hiding this comment

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

I don't think we need these extra dependencies, it should be a few lines of code to do this manually, either with strings in the python code, or reading files from a new directory in tests.

Copy link
Owner

Choose a reason for hiding this comment

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

you can probably use python files with a multiline string at the end of the for expected stdout.

Copy link
Author

Choose a reason for hiding this comment

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

I rewrote the tests using vanilla python.

# these packages are used in tests so install the latest version
# no binaries for 3.7
asyncpg; python_version>='3.8'
Expand Down
38 changes: 37 additions & 1 deletion requirements/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --output-file=requirements/testing.txt --resolver=backtracking requirements/testing.in
# pip-compile --output-file=requirements/testing.txt requirements/testing.in
#
arrow==1.3.0
# via inform
asyncpg==0.27.0 ; python_version >= "3.8"
# via -r requirements/testing.in
attrs==22.2.0
Expand All @@ -14,24 +16,38 @@ click==8.1.3
# via black
coverage[toml]==7.2.2
# via -r requirements/testing.in
decopatch==1.4.10
# via parametrize-from-file
exceptiongroup==1.1.3
# via pytest
greenlet==3.0.0
# via sqlalchemy
inform==1.28
# via nestedtext
iniconfig==2.0.0
# via pytest
makefun==1.15.1
# via decopatch
markdown-it-py==2.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
more-itertools==8.14.0
# via parametrize-from-file
multidict==6.0.4 ; python_version >= "3.8"
# via -r requirements/testing.in
mypy-extensions==1.0.0
# via black
nestedtext==3.6
# via parametrize-from-file
numpy==1.24.2 ; python_version >= "3.8"
# via -r requirements/testing.in
packaging==23.0
# via
# black
# pytest
parametrize-from-file==0.18.0
# via -r requirements/testing.in
pathspec==0.11.1
# via black
platformdirs==3.2.0
Expand All @@ -45,21 +61,41 @@ pygments==2.15.0
pytest==7.2.2
# via
# -r requirements/testing.in
# parametrize-from-file
# pytest-mock
# pytest-pretty
# pytest-tmp-files
pytest-mock==3.10.0
# via -r requirements/testing.in
pytest-pretty==1.2.0
# via -r requirements/testing.in
pytest-tmp-files==0.0.1
# via -r requirements/testing.in
python-dateutil==2.8.2
# via
# arrow
# pytest-tmp-files
pyyaml==6.0.1
# via parametrize-from-file
rich==13.3.3
# via pytest-pretty
six==1.16.0
# via
# inform
# python-dateutil
sqlalchemy==2.0.8
# via -r requirements/testing.in
tidyexc==0.10.0
# via parametrize-from-file
toml==0.10.2
# via parametrize-from-file
tomli==2.0.1
# via
# black
# coverage
# pytest
types-python-dateutil==2.8.19.14
# via arrow
typing-extensions==4.5.0
# via
# pydantic
Expand Down
Loading