Coverage for dataexcept/wrapping.py: 100%

28 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-03 20:46 +0000

1"""Turning a third-party exception into a DataExcept one. 

2 

3The pattern this replaces is everywhere in pipeline code:: 

4 

5 try: 

6 frame = pd.read_csv(path) 

7 except OSError as exc: 

8 raise DataLoadingError(path, exc) from exc 

9 

10It is easy to write and easy to get subtly wrong: forget the ``from exc`` and 

11the traceback stops showing what actually failed; pass the original to the 

12wrong parameter and it is not recorded at all; catch too broadly and a 

13``KeyboardInterrupt`` becomes a data-loading error. 

14 

15:func:`wrap` and :func:`wrapping` do the same thing with the wiring settled. 

16The original is passed to whichever constructor parameter takes a cause -- 

17``original``, ``original_exception`` or ``cause``, whichever that class uses -- 

18and set as ``__cause__`` either way, so a traceback always shows both. 

19""" 

20 

21from __future__ import annotations 

22 

23import contextlib 

24import inspect 

25from typing import Any, Iterator, Tuple, Type, Union 

26 

27from .base import DataExceptError 

28 

29__all__ = ["wrap", "wrapping"] 

30 

31#: Constructor parameter names used across the package for a wrapped 

32#: exception. Checked in this order; the first the target accepts wins. 

33_CAUSE_PARAMETERS = ("original", "original_exception", "cause") 

34 

35Catchable = Union[Type[BaseException], Tuple[Type[BaseException], ...]] 

36 

37 

38def _cause_parameter(target: Type[DataExceptError]) -> str | None: 

39 """Return the parameter of *target* that takes a wrapped exception.""" 

40 try: 

41 parameters = inspect.signature(target.__init__).parameters 

42 except (TypeError, ValueError): # pragma: no cover - builtins and C types 

43 return None 

44 for name in _CAUSE_PARAMETERS: 

45 if name in parameters: 

46 return name 

47 return None 

48 

49 

50def wrap( 

51 original: BaseException, 

52 target: Type[DataExceptError], 

53 /, 

54 **kwargs: Any, 

55) -> DataExceptError: 

56 """Build *target* from *original*, recording it as the cause. 

57 

58 Extra keyword arguments go to the constructor:: 

59 

60 raise wrap(exc, DataLoadingError, source=path) from exc 

61 

62 If *target* accepts a cause parameter, *original* is passed to it. Either 

63 way ``__cause__`` is set, so a traceback shows the underlying failure even 

64 for a class that records nothing. 

65 

66 An explicit ``original``/``cause`` keyword wins, so a caller can still say 

67 exactly what they mean. 

68 """ 

69 parameter = _cause_parameter(target) 

70 if parameter is not None and parameter not in kwargs: 

71 kwargs[parameter] = original 

72 

73 exception = target(**kwargs) 

74 # Set unconditionally: the target may record nothing, and the point is that 

75 # the traceback shows what actually failed. 

76 exception.__cause__ = original 

77 return exception 

78 

79 

80@contextlib.contextmanager 

81def wrapping( 

82 catch: Catchable, 

83 target: Type[DataExceptError], 

84 /, 

85 **kwargs: Any, 

86) -> Iterator[None]: 

87 """Translate *catch* raised inside the block into *target*. 

88 

89 :: 

90 

91 with wrapping(OSError, DataLoadingError, source=path): 

92 frame = pd.read_csv(path) 

93 

94 Only exceptions matching *catch* are translated; everything else propagates 

95 untouched, including anything already raised by this package. Because 

96 *catch* is given explicitly there is no default broad ``except``, so a 

97 ``KeyboardInterrupt`` or a bug in the block is never relabelled as a data 

98 error. 

99 """ 

100 try: 

101 yield 

102 except catch as exc: 

103 raise wrap(exc, target, **kwargs) from exc