Coverage for muutils/errormode.py: 59%

75 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2025-04-04 03:33 -0600

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

2 

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

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

5 

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

7 

8""" 

9 

10from __future__ import annotations 

11 

12import sys 

13import typing 

14import types 

15import warnings 

16from enum import Enum 

17 

18 

19class 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 

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

29 

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

31GLOBAL_LOG_FUNC: LoggingFunc = print 

32 

33 

34def 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 

78class 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 

215ERROR_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"