Coverage for nilearn/_utils/logger.py: 35%

65 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-20 10:58 +0200

1"""Logging facility for nilearn.""" 

2 

3import inspect 

4import traceback 

5from pathlib import Path 

6 

7from sklearn.base import BaseEstimator 

8 

9 

10def _has_rich(): 

11 """Check if rich is installed.""" 

12 try: 

13 import rich # noqa: F401 

14 

15 return True 

16 

17 except ImportError: 

18 return False 

19 

20 

21if _has_rich(): 21 ↛ 22line 21 didn't jump to line 22 because the condition on line 21 was never true

22 from rich import print 

23 from rich.markup import escape 

24 

25 

26# The technique used in the log() function only applies to CPython, because 

27# it uses the inspect module to walk the call stack. 

28def log( 

29 msg, 

30 verbose=1, 

31 object_classes=(BaseEstimator,), 

32 stack_level=None, 

33 msg_level=1, 

34 with_traceback=False, 

35): 

36 """Display a message to the user, depending on the verbosity level. 

37 

38 This function allows to display some information that references an object 

39 that is significant to the user, instead of a internal function. The goal 

40 is to make user's code as simple to debug as possible. 

41 

42 Parameters 

43 ---------- 

44 msg : str 

45 Message to display. 

46 

47 verbose : int, default=1 

48 Current verbosity level. Message is displayed if this value is greater 

49 or equal to msg_level. 

50 

51 object_classes : tuple of type, default=(BaseEstimator, ) 

52 Classes that should appear to emit the message. 

53 

54 stack_level : int or None, default=None 

55 If no object in the call stack matches object_classes, go back that 

56 amount in the call stack and display class/function name thereof. 

57 If None is passed this should show 

58 the first nilearn public function in the stack. 

59 

60 msg_level : int, default=1 

61 Verbosity level at and above which message should be displayed to the 

62 user. Most of the time this parameter can be left unchanged. 

63 

64 Notes 

65 ----- 

66 This function does tricky things to ensure that the proper object is 

67 referenced in the message. If it is called e.g. inside a function that is 

68 called by a method of an object inheriting from any class in 

69 object_classes, then the name of the object (and the method) will be 

70 displayed to the user. If several matching objects exist in the call 

71 stack, the highest one is used (first call chronologically), because this 

72 is the one which is most likely to have been written in the user's script. 

73 

74 """ 

75 if verbose < msg_level: 

76 return 

77 if stack_level is None: 

78 stack_level = find_stack_level() - 2 

79 stack = inspect.stack() 

80 object_frame = None 

81 object_self = None 

82 for f in reversed(stack): 

83 frame = f[0] 

84 current_self = frame.f_locals.get("self", None) 

85 if isinstance(current_self, object_classes): 

86 object_frame = frame 

87 func_name = f[3] 

88 object_self = current_self 

89 break 

90 

91 if object_frame is None: # no object found: use stack_level 

92 if stack_level >= len(stack): 

93 func_name = "<top_level>" 

94 else: 

95 object_frame, _, _, func_name = stack[stack_level][:4] 

96 object_self = object_frame.f_locals.get("self", None) 

97 

98 if object_self is not None: 

99 func_name = f"{object_self.__class__.__name__}.{func_name}" 

100 

101 if _has_rich(): 

102 print(f"[blue]\\[{func_name}][/blue] {escape(msg)}") 

103 else: 

104 print(f"[{func_name}] {msg}") 

105 

106 if with_traceback: 

107 traceback.print_exc() 

108 

109 

110def compose_err_msg(msg, **kwargs): 

111 """Append key-value pairs to msg, for display. # noqa: D301. 

112 

113 Parameters 

114 ---------- 

115 msg : string 

116 Arbitrary message. 

117 

118 kwargs : dict, optional 

119 Arbitrary dictionary. 

120 

121 Returns 

122 ------- 

123 updated_msg : string 

124 msg, with "key: value" appended. Only string values are appended. 

125 

126 Example 

127 ------- 

128 >>> compose_err_msg('Error message with arguments...', arg_num=123, \ 

129 arg_str='filename.nii', arg_bool=True) 

130 'Error message with arguments...\\narg_str: filename.nii' 

131 >>> 

132 

133 """ 

134 updated_msg = msg 

135 for k, v in sorted(kwargs.items()): 

136 if isinstance(v, str): # print only str-like arguments 

137 updated_msg += f"\n{k}: {v}" 

138 

139 return updated_msg 

140 

141 

142def find_stack_level() -> int: 

143 """ 

144 Find the first place in the stack that is not inside nilearn 

145 (tests notwithstanding). 

146 

147 Taken from the pandas codebase. 

148 https://github.com/pandas-dev/pandas/tree/main/pandas/util/_exceptions.py#L37 

149 """ 

150 import nilearn as nil 

151 

152 pkg_dir = Path(nil.__file__).parent 

153 

154 # https://stackoverflow.com/questions/17407119/python-inspect-stack-is-slow 

155 frame = inspect.currentframe() 

156 try: 

157 n = 0 

158 while frame: 158 ↛ 169line 158 didn't jump to line 169 because the condition on line 158 was always true

159 filename = inspect.getfile(frame) 

160 is_test_file = Path(filename).name.startswith("test_") 

161 in_nilearn_code = filename.startswith(str(pkg_dir)) 

162 if not in_nilearn_code or is_test_file: 

163 break 

164 frame = frame.f_back 

165 n += 1 

166 finally: 

167 # See note in 

168 # https://docs.python.org/3/library/inspect.html#inspect.Traceback 

169 del frame 

170 return n 

171 

172 

173def one_level_deeper(): 

174 """Use for testing find_stack_level. 

175 

176 Needs to be in a module that does not start with 'test' 

177 """ 

178 return find_stack_level()