Skip to content

Commit 1a3ced5

Browse files
ConchylicultorThe kauldron Authors
authored andcommitted
Better traceback with Config objects
PiperOrigin-RevId: 867552564
1 parent 936fd62 commit 1a3ced5

File tree

3 files changed

+97
-3
lines changed

3 files changed

+97
-3
lines changed

kauldron/konfig/configdict_base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ def __init__(
6969
init_dict = dict(init_dict or {})
7070
init_dict = _maybe_update_init_dict(init_dict) # pytype: disable=name-error
7171

72+
# Capture the frame stack (to trace back where the ConfigDict is created)
73+
object.__setattr__(self, '_frame', utils.FrameStack.from_current())
74+
7275
# Normalize here rather than at the individul field level (`__setattr__`),
7376
# to have a global cache for all shared values (so shared fields are
7477
# correctly handled).

kauldron/konfig/configdict_proxy.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ def resolve(cfg, *, freeze=True):
128128
Returns:
129129
The resolved config.
130130
"""
131+
# Hide the function from the traceback (in Pytest and IPython 7).
132+
__tracebackhide__ = True # pylint: disable=unused-variable,invalid-name
131133

132134
# Check if the config has a `_konfig_experimental_nofreeze` key.
133135
# TODO(klausg): make freeze=False the default and remove this.
@@ -140,6 +142,7 @@ def resolve(cfg, *, freeze=True):
140142
return _ConstructorResolver(freeze=freeze)._resolve_value(cfg) # pylint: disable=protected-access
141143
except Exception as e: # pylint: disable=broad-exception-caught
142144
logging.info(f'Full config (failing): {cfg}') # pylint: disable=logging-fstring-interpolation
145+
utils.filter_traceback(e.__traceback__)
143146
epy.reraise(e, 'Error resolving the config:\n')
144147

145148

@@ -250,8 +253,11 @@ def _resolve_dict(self, value):
250253
for k, v in kwargs.items()
251254
}
252255
args = [kwargs.pop(str(i)) for i in range(num_args(kwargs))]
253-
with epy.maybe_reraise(prefix=lambda: _make_cfg_error_msg(value)):
256+
try:
254257
obj = constructor(*args, **kwargs)
258+
except Exception as e: # pylint: disable=broad-exception-caught
259+
e = _wrap_cfg_error(e, value, frame=value._frame) # pylint: disable=protected-access
260+
raise e from e.__cause__
255261
# Allow the object to save the config it is comming from.
256262
if hasattr(type(obj), '__post_konfig_resolve__'):
257263
obj.__post_konfig_resolve__(value)
@@ -283,12 +289,40 @@ def num_args(obj: Mapping[str, Any]) -> int:
283289
return arg_id # pylint: disable=undefined-loop-variable,undefined-variable
284290

285291

286-
def _make_cfg_error_msg(cfg: ml_collections.ConfigDict) -> str:
292+
def _wrap_cfg_error(
293+
e: Exception,
294+
cfg: ml_collections.ConfigDict,
295+
frame: utils.FrameStack,
296+
) -> Exception:
297+
"""Wrap the exception with the config information."""
287298
cfg_str = repr(cfg)
288299
cfg_str = cfg_str.removeprefix('<ConfigDict[').removesuffix(']>')
289300
if len(cfg_str) > 300: # `textwrap.shorten` remove `\n` so don't use it
290301
cfg_str = cfg_str[:295] + '[...]'
291-
return f'Error while constructing cfg: {cfg_str}\n'
302+
msg = (
303+
f'For: {cfg_str}\n'
304+
'Look at the the exception cause above to see where the ConfigDict'
305+
' was created.\n'
306+
'======================================================================\n'
307+
)
308+
309+
cause = ValueError('The ConfigDict was created here.').with_traceback(
310+
frame.as_traceback()
311+
)
312+
313+
# Backward compatibility.
314+
if not hasattr(epy.reraise_utils, 'wrap_error'):
315+
try:
316+
epy.reraise(e, prefix=msg)
317+
except Exception as exc: # pylint: disable=broad-exception-caught
318+
e = exc
319+
else:
320+
e = epy.reraise_utils.wrap_error(e, prefix=msg)
321+
322+
while e.__cause__ is not None:
323+
e = e.__cause__
324+
e.__cause__ = cause
325+
return e
292326

293327

294328
def _as_dict(values: Mapping[str, Any]) -> dict[str, Any]:

kauldron/konfig/utils.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@
1414

1515
"""Utils."""
1616

17+
from __future__ import annotations
18+
1719
import ast
1820
import dataclasses
1921
import functools
2022
import itertools
2123
import os
24+
import sys
25+
import types
2226
import typing
2327
from typing import Any, Generic, TypeVar
2428

@@ -172,3 +176,56 @@ def maybe_decode_json_key(key: Any) -> Any:
172176
return ast.literal_eval(key.removeprefix(_JSON_RAW_PREFIX))
173177
else:
174178
return key
179+
180+
181+
@dataclasses.dataclass(slots=True, kw_only=True)
182+
class FrameInfo:
183+
frame: types.FrameType
184+
lasti: int
185+
lineno: int
186+
187+
188+
class FrameStack(list[FrameInfo]):
189+
"""A list of FrameInfo objects.
190+
191+
Allow to reraise the stack trace from the current frame later on.
192+
193+
Note: This keep the frame objects alive.
194+
"""
195+
196+
@classmethod
197+
def from_current(cls, depth: int = 0) -> FrameStack:
198+
"""Returns the current frame stack."""
199+
stack = cls()
200+
frame = sys._getframe(depth + 1) # pylint: disable=protected-access
201+
while frame:
202+
# Do not capture internals IPython frames.
203+
if frame.f_code.co_filename.endswith('IPython/core/interactiveshell.py'):
204+
break
205+
# Skip the konfig frames.
206+
if '/kauldron/konfig/' in frame.f_code.co_filename:
207+
frame = frame.f_back
208+
continue
209+
210+
# Capture the current f_lasti and f_lineno as those values get updated
211+
# as execution continues.
212+
info = FrameInfo(frame=frame, lasti=frame.f_lasti, lineno=frame.f_lineno) # pytype: disable=attribute-error
213+
stack.append(info)
214+
frame = frame.f_back # pytype: disable=attribute-error
215+
return stack
216+
217+
def as_traceback(self) -> types.TracebackType:
218+
"""Returns a traceback object for the current stack."""
219+
tb = None
220+
for info in self:
221+
tb = types.TracebackType(tb, info.frame, info.lasti, info.lineno)
222+
return tb # pytype: disable=bad-return-type
223+
224+
225+
def filter_traceback(tb: types.TracebackType) -> None:
226+
"""Returns a filtered version of the traceback."""
227+
# TODO(epot): Should also actually remove the tb for non-IPython frames.
228+
while tb:
229+
if '/kauldron/konfig/' in tb.tb_frame.f_code.co_filename:
230+
tb.tb_frame.f_locals['__tracebackhide__'] = True
231+
tb = tb.tb_next

0 commit comments

Comments
 (0)