Skip to content

Commit 24efa58

Browse files
committed
add option to include stack trace in debug() output
1 parent ec406ff commit 24efa58

File tree

5 files changed

+268
-80
lines changed

5 files changed

+268
-80
lines changed

devtools/debug.py

Lines changed: 95 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,47 @@
2222
StrType = str
2323

2424

25+
class DebugFrame:
26+
__slots__ = 'function', 'path', 'lineno'
27+
28+
@staticmethod
29+
def from_call_frame(call_frame: 'FrameType') -> 'DebugFrame':
30+
from pathlib import Path
31+
32+
function = call_frame.f_code.co_name
33+
34+
path = Path(call_frame.f_code.co_filename)
35+
if path.is_absolute():
36+
# make the path relative
37+
cwd = Path('.').resolve()
38+
try:
39+
path = path.relative_to(cwd)
40+
except ValueError:
41+
# happens if filename path is not within CWD
42+
pass
43+
44+
lineno = call_frame.f_lineno
45+
46+
return DebugFrame(function, str(path), lineno)
47+
48+
def __init__(self, function: str, path: str, lineno: int):
49+
self.function = function
50+
self.path = path
51+
self.lineno = lineno
52+
53+
def __str__(self) -> StrType:
54+
return self.str()
55+
56+
def str(self, highlight: bool = False) -> StrType:
57+
if highlight:
58+
return (
59+
f'{sformat(self.path, sformat.magenta)}:{sformat(self.lineno, sformat.green)} '
60+
f'{sformat(self.function, sformat.green, sformat.italic)}'
61+
)
62+
else:
63+
return f'{self.path}:{self.lineno} {self.function}'
64+
65+
2566
class DebugArgument:
2667
__slots__ = 'value', 'name', 'extra'
2768

@@ -66,43 +107,37 @@ class DebugOutput:
66107
"""
67108

68109
arg_class = DebugArgument
69-
__slots__ = 'filename', 'lineno', 'frame', 'arguments', 'warning'
110+
__slots__ = 'call_context', 'arguments', 'warning'
70111

71112
def __init__(
72113
self,
73114
*,
74-
filename: str,
75-
lineno: int,
76-
frame: str,
115+
call_context: 'List[DebugFrame]',
77116
arguments: 'List[DebugArgument]',
78117
warning: 'Union[None, str, bool]' = None,
79118
) -> None:
80-
self.filename = filename
81-
self.lineno = lineno
82-
self.frame = frame
119+
self.call_context = call_context
83120
self.arguments = arguments
84121
self.warning = warning
85122

86123
def str(self, highlight: bool = False) -> StrType:
87-
if highlight:
88-
prefix = (
89-
f'{sformat(self.filename, sformat.magenta)}:{sformat(self.lineno, sformat.green)} '
90-
f'{sformat(self.frame, sformat.green, sformat.italic)}'
91-
)
92-
if self.warning:
124+
prefix = '\n'.join(x.str(highlight) for x in self.call_context)
125+
126+
if self.warning:
127+
if highlight:
93128
prefix += sformat(f' ({self.warning})', sformat.dim)
94-
else:
95-
prefix = f'{self.filename}:{self.lineno} {self.frame}'
96-
if self.warning:
129+
else:
97130
prefix += f' ({self.warning})'
98-
return f'{prefix}\n ' + '\n '.join(a.str(highlight) for a in self.arguments)
131+
132+
return prefix + '\n ' + '\n '.join(a.str(highlight) for a in self.arguments)
99133

100134
def __str__(self) -> StrType:
101135
return self.str()
102136

103137
def __repr__(self) -> StrType:
138+
context = self.call_context[-1]
104139
arguments = ' '.join(str(a) for a in self.arguments)
105-
return f'<DebugOutput {self.filename}:{self.lineno} {self.frame} arguments: {arguments}>'
140+
return f'<DebugOutput {context.path}:{context.lineno} {context.function} arguments: {arguments}>'
106141

107142

108143
class Debug:
@@ -118,9 +153,10 @@ def __call__(
118153
file_: 'Any' = None,
119154
flush_: bool = True,
120155
frame_depth_: int = 2,
156+
trace_: bool = False,
121157
**kwargs: 'Any',
122158
) -> 'Any':
123-
d_out = self._process(args, kwargs, frame_depth_)
159+
d_out = self._process(args, kwargs, frame_depth_, trace_)
124160
s = d_out.str(use_highlight(self._highlight, file_))
125161
print(s, file=file_, flush=flush_)
126162
if kwargs:
@@ -130,8 +166,25 @@ def __call__(
130166
else:
131167
return args
132168

133-
def format(self, *args: 'Any', frame_depth_: int = 2, **kwargs: 'Any') -> DebugOutput:
134-
return self._process(args, kwargs, frame_depth_)
169+
def trace(
170+
self,
171+
*args: 'Any',
172+
file_: 'Any' = None,
173+
flush_: bool = True,
174+
frame_depth_: int = 2,
175+
**kwargs: 'Any',
176+
) -> 'Any':
177+
return self.__call__(
178+
*args,
179+
file_=file_,
180+
flush_=flush_,
181+
frame_depth_=frame_depth_ + 1,
182+
trace_=True,
183+
**kwargs,
184+
)
185+
186+
def format(self, *args: 'Any', frame_depth_: int = 2, trace_: bool = False, **kwargs: 'Any') -> DebugOutput:
187+
return self._process(args, kwargs, frame_depth_, trace_)
135188

136189
def breakpoint(self) -> None:
137190
import pdb
@@ -141,38 +194,24 @@ def breakpoint(self) -> None:
141194
def timer(self, name: 'Optional[str]' = None, *, verbose: bool = True, file: 'Any' = None, dp: int = 3) -> Timer:
142195
return Timer(name=name, verbose=verbose, file=file, dp=dp)
143196

144-
def _process(self, args: 'Any', kwargs: 'Any', frame_depth: int) -> DebugOutput:
197+
def _process(self, args: 'Any', kwargs: 'Any', frame_depth: int, trace: bool) -> DebugOutput:
145198
"""
146199
BEWARE: this must be called from a function exactly `frame_depth` levels below the top of the stack.
147200
"""
148201
# HELP: any errors other than ValueError from _getframe? If so please submit an issue
149202
try:
150203
call_frame: 'FrameType' = sys._getframe(frame_depth)
151204
except ValueError:
152-
# "If [ValueError] is deeper than the call stack, ValueError is raised"
205+
# "If [the given frame depth] is deeper than the call stack,
206+
# ValueError is raised"
153207
return self.output_class(
154-
filename='<unknown>',
155-
lineno=0,
156-
frame='',
208+
call_context=[DebugFrame(function='', path='<unknown>', lineno=0)],
157209
arguments=list(self._args_inspection_failed(args, kwargs)),
158210
warning=self._show_warnings and 'error parsing code, call stack too shallow',
159211
)
160212

161-
function = call_frame.f_code.co_name
162-
163-
from pathlib import Path
164-
165-
path = Path(call_frame.f_code.co_filename)
166-
if path.is_absolute():
167-
# make the path relative
168-
cwd = Path('.').resolve()
169-
try:
170-
path = path.relative_to(cwd)
171-
except ValueError:
172-
# happens if filename path is not within CWD
173-
pass
213+
call_context = _make_call_context(call_frame, trace)
174214

175-
lineno = call_frame.f_lineno
176215
warning = None
177216

178217
import executing
@@ -183,17 +222,15 @@ def _process(self, args: 'Any', kwargs: 'Any', frame_depth: int) -> DebugOutput:
183222
arguments = list(self._args_inspection_failed(args, kwargs))
184223
else:
185224
ex = source.executing(call_frame)
186-
function = ex.code_qualname()
225+
call_context[-1].function = ex.code_qualname()
187226
if not ex.node:
188227
warning = 'executing failed to find the calling node'
189228
arguments = list(self._args_inspection_failed(args, kwargs))
190229
else:
191230
arguments = list(self._process_args(ex, args, kwargs))
192231

193232
return self.output_class(
194-
filename=str(path),
195-
lineno=lineno,
196-
frame=function,
233+
call_context=call_context,
197234
arguments=arguments,
198235
warning=self._show_warnings and warning,
199236
)
@@ -225,4 +262,18 @@ def _process_args(self, ex: 'Any', args: 'Any', kwargs: 'Any') -> 'Generator[Deb
225262
yield self.output_class.arg_class(value, name=name, variable=kw_arg_names.get(name))
226263

227264

265+
def _make_call_context(call_frame: 'Optional[FrameType]', trace: bool) -> 'List[DebugFrame]':
266+
call_context: 'List[DebugFrame]' = []
267+
268+
while call_frame:
269+
frame_info = DebugFrame.from_call_frame(call_frame)
270+
call_context.insert(0, frame_info)
271+
call_frame = call_frame.f_back
272+
273+
if not trace:
274+
break
275+
276+
return call_context
277+
278+
228279
debug = Debug()

requirements/testing.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ coverage[toml]
22
pytest
33
pytest-mock
44
pytest-pretty
5+
pytest-tmp-files
6+
parametrize-from-file
57
# these packages are used in tests so install the latest version
68
# no binaries for 3.7
79
asyncpg; python_version>='3.8'

requirements/testing.txt

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
# This file is autogenerated by pip-compile with Python 3.10
33
# by the following command:
44
#
5-
# pip-compile --output-file=requirements/testing.txt --resolver=backtracking requirements/testing.in
5+
# pip-compile --output-file=requirements/testing.txt requirements/testing.in
66
#
7+
arrow==1.3.0
8+
# via inform
79
asyncpg==0.27.0 ; python_version >= "3.8"
810
# via -r requirements/testing.in
911
attrs==22.2.0
@@ -14,24 +16,38 @@ click==8.1.3
1416
# via black
1517
coverage[toml]==7.2.2
1618
# via -r requirements/testing.in
19+
decopatch==1.4.10
20+
# via parametrize-from-file
1721
exceptiongroup==1.1.3
1822
# via pytest
23+
greenlet==3.0.0
24+
# via sqlalchemy
25+
inform==1.28
26+
# via nestedtext
1927
iniconfig==2.0.0
2028
# via pytest
29+
makefun==1.15.1
30+
# via decopatch
2131
markdown-it-py==2.2.0
2232
# via rich
2333
mdurl==0.1.2
2434
# via markdown-it-py
35+
more-itertools==8.14.0
36+
# via parametrize-from-file
2537
multidict==6.0.4 ; python_version >= "3.8"
2638
# via -r requirements/testing.in
2739
mypy-extensions==1.0.0
2840
# via black
41+
nestedtext==3.6
42+
# via parametrize-from-file
2943
numpy==1.24.2 ; python_version >= "3.8"
3044
# via -r requirements/testing.in
3145
packaging==23.0
3246
# via
3347
# black
3448
# pytest
49+
parametrize-from-file==0.18.0
50+
# via -r requirements/testing.in
3551
pathspec==0.11.1
3652
# via black
3753
platformdirs==3.2.0
@@ -45,21 +61,41 @@ pygments==2.15.0
4561
pytest==7.2.2
4662
# via
4763
# -r requirements/testing.in
64+
# parametrize-from-file
4865
# pytest-mock
4966
# pytest-pretty
67+
# pytest-tmp-files
5068
pytest-mock==3.10.0
5169
# via -r requirements/testing.in
5270
pytest-pretty==1.2.0
5371
# via -r requirements/testing.in
72+
pytest-tmp-files==0.0.1
73+
# via -r requirements/testing.in
74+
python-dateutil==2.8.2
75+
# via
76+
# arrow
77+
# pytest-tmp-files
78+
pyyaml==6.0.1
79+
# via parametrize-from-file
5480
rich==13.3.3
5581
# via pytest-pretty
82+
six==1.16.0
83+
# via
84+
# inform
85+
# python-dateutil
5686
sqlalchemy==2.0.8
5787
# via -r requirements/testing.in
88+
tidyexc==0.10.0
89+
# via parametrize-from-file
90+
toml==0.10.2
91+
# via parametrize-from-file
5892
tomli==2.0.1
5993
# via
6094
# black
6195
# coverage
6296
# pytest
97+
types-python-dateutil==2.8.19.14
98+
# via arrow
6399
typing-extensions==4.5.0
64100
# via
65101
# pydantic

0 commit comments

Comments
 (0)