1"""provides `ErrorMode` enum for handling errors consistently

2

3 pass an `error_mode: ErrorMode` to a function to specify how to handle a certain kind of exception.

4 That function then instead of `raise`ing or `warnings.warn`ing, calls `error_mode.process` with the message and the exception.

5

6 you can also specify the exception class to raise, the warning class to use, and the source of the exception/warning.

7

8"""

9

10 from __future__ import annotations

11

12 import sys

13 import typing

14 import types

15 import warnings

16 from enum import Enum

17

18

19 class WarningFunc(typing.Protocol):

20 def __call__(

21 self,

22 msg: str,

23 category: typing.Type[Warning],

24 source: typing.Any = None,

25 ) -> None: ...

26

27

28 LoggingFunc = typing.Callable[[str], None]

29

30 GLOBAL_WARN_FUNC: WarningFunc = warnings.warn # type: ignore[assignment]

31 GLOBAL_LOG_FUNC: LoggingFunc = print

32

33

34 def custom_showwarning(

35 message: Warning | str,

36 category: typing.Type[Warning] | None = None,

37 filename: str | None = None,

38 lineno: int | None = None,

39 file: typing.Optional[typing.TextIO] = None,

40 line: typing.Optional[str] = None,

41) -> None:

42 if category is None:

43 category = UserWarning

44 # Get the frame where process() was called

45 # Adjusted to account for the extra function call

46 frame: types.FrameType = sys._getframe(2)

47 # get globals and traceback

48 traceback: types.TracebackType = types.TracebackType(

49 None, frame, frame.f_lasti, frame.f_lineno

50 )

51 _globals: dict[str, typing.Any] = frame.f_globals

52 # init the new warning and add the traceback

53 if isinstance(message, str):

54 message = category(message)

55 message = message.with_traceback(traceback)

56

57 # Call the original showwarning function

58 warnings.warn_explicit(

59 message=message,

60 category=category,

61 # filename arg if it's passed, otherwise use the frame's filename

62 filename=frame.f_code.co_filename,

63 lineno=frame.f_lineno,

64 module=frame.f_globals.get("__name__", "__main__"),

65 registry=_globals.setdefault("__warningregistry__", {}),

66 module_globals=_globals,

67 )

68 # warnings._showwarning_orig(

69 # message,

70 # category,

71 # frame.f_code.co_filename,

72 # frame.f_lineno,

73 # file,

74 # line,

75 # )

76

77

78 class ErrorMode(Enum):

79 """Enum for handling errors consistently

80

81 pass one of the instances of this enum to a function to specify how to handle a certain kind of exception.

82

83 That function then instead of `raise`ing or `warnings.warn`ing, calls `error_mode.process` with the message and the exception.

84 """

85

86 EXCEPT = "except"

87 WARN = "warn"

88 LOG = "log"

89 IGNORE = "ignore"

90

91 def process(

92 self,

93 msg: str,

94 except_cls: typing.Type[Exception] = ValueError,

95 warn_cls: typing.Type[Warning] = UserWarning,

96 except_from: typing.Optional[Exception] = None,

97 warn_func: WarningFunc | None = None,

98 log_func: LoggingFunc | None = None,

99 ):

100 """process an exception or warning according to the error mode

101

102 # Parameters:

103 - `msg : str`

104 message to pass to `except_cls` or `warn_func`

105 - `except_cls : typing.Type[Exception]`

106 exception class to raise, must be a subclass of `Exception`

107 (defaults to `ValueError`)

108 - `warn_cls : typing.Type[Warning]`

109 warning class to use, must be a subclass of `Warning`

110 (defaults to `UserWarning`)

111 - `except_from : typing.Optional[Exception]`

112 will `raise except_cls(msg) from except_from` if not `None`

113 (defaults to `None`)

114 - `warn_func : WarningFunc | None`

115 function to use for warnings, must have the signature `warn_func(msg: str, category: typing.Type[Warning], source: typing.Any = None) -> None`

116 (defaults to `None`)

117 - `log_func : LoggingFunc | None`

118 function to use for logging, must have the signature `log_func(msg: str) -> None`

119 (defaults to `None`)

120

121 # Raises:

122 - `except_cls` : _description_

123 - `except_cls` : _description_

124 - `ValueError` : _description_

125 """

126 if self is ErrorMode.EXCEPT:

127 # except, possibly with a chained exception

128 frame: types.FrameType = sys._getframe(1)

129 traceback: types.TracebackType = types.TracebackType(

130 None, frame, frame.f_lasti, frame.f_lineno

131 )

132

133 # Attach the new traceback to the exception and raise it without the internal call stack

134 if except_from is not None:

135 raise except_cls(msg).with_traceback(traceback) from except_from

136 else:

137 raise except_cls(msg).with_traceback(traceback)

138 elif self is ErrorMode.WARN:

139 # get global warn function if not passed

140 if warn_func is None:

141 warn_func = GLOBAL_WARN_FUNC

142 # augment warning message with source

143 if except_from is not None:

144 msg = f"{msg}\n\tSource of warning: {except_from}"

145 if warn_func == warnings.warn:

146 custom_showwarning(msg, category=warn_cls)

147 else:

148 # Use the provided warn_func as-is

149 warn_func(msg, category=warn_cls)

150 elif self is ErrorMode.LOG:

151 # get global log function if not passed

152 if log_func is None:

153 log_func = GLOBAL_LOG_FUNC

154 # log

155 log_func(msg)

156 elif self is ErrorMode.IGNORE:

157 # do nothing

158 pass

159 else:

160 raise ValueError(f"Unknown error mode {self}")

161

162 @classmethod

163 def from_any(

164 cls,

165 mode: "str|ErrorMode",

166 allow_aliases: bool = True,

167 allow_prefix: bool = True,

168 ) -> ErrorMode:

169 """initialize an `ErrorMode` from a string or an `ErrorMode` instance"""

170 if isinstance(mode, ErrorMode):

171 return mode

172 elif isinstance(mode, str):

173 # strip

174 mode = mode.strip()

175

176 # remove prefix

177 if allow_prefix and mode.startswith("ErrorMode."):

178 mode = mode[len("ErrorMode.") :]

179

180 # lowercase and strip again

181 mode = mode.strip().lower()

182

183 if not allow_aliases:

184 # try without aliases

185 try:

186 return ErrorMode(mode)

187 except ValueError as e:

188 raise KeyError(f"Unknown error mode {mode = }") from e

189 else:

190 # look up in aliases map

191 return ERROR_MODE_ALIASES[mode]

192 else:

193 raise TypeError(

194 f"Expected {ErrorMode = } or str, got {type(mode) = } {mode = }"

195 )

196

197 def __str__(self) -> str:

198 return f"ErrorMode.{self.value.capitalize()}"

199

200 def __repr__(self) -> str:

201 return str(self)

202

203 def serialize(self) -> str:

204 return str(self)

205

206 @classmethod

207 def load(cls, data: str) -> ErrorMode:

208 return cls.from_any(

209 data,

210 allow_aliases=False,

211 allow_prefix=True,

212 )

213

214

215 ERROR_MODE_ALIASES: dict[str, ErrorMode] = {

216 # base

217 "except": ErrorMode.EXCEPT,

218 "warn": ErrorMode.WARN,

219 "log": ErrorMode.LOG,

220 "ignore": ErrorMode.IGNORE,

221 # except

222 "e": ErrorMode.EXCEPT,

223 "error": ErrorMode.EXCEPT,

224 "err": ErrorMode.EXCEPT,

225 "raise": ErrorMode.EXCEPT,

226 # warn

227 "w": ErrorMode.WARN,

228 "warning": ErrorMode.WARN,

229 # log

230 "l": ErrorMode.LOG,

231 "print": ErrorMode.LOG,

232 "output": ErrorMode.LOG,

233 "show": ErrorMode.LOG,

234 "display": ErrorMode.LOG,

235 # ignore

236 "i": ErrorMode.IGNORE,

237 "silent": ErrorMode.IGNORE,

238 "quiet": ErrorMode.IGNORE,

239 "nothing": ErrorMode.IGNORE,

240}

241"map of string aliases to `ErrorMode` instances"