Coverage for python/lsst/utils/introspection.py: 11%

92 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-01 15:14 -0700

1# This file is part of utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11# 

12"""Utilities relating to introspection in python.""" 

13 

14from __future__ import annotations 

15 

16__all__ = [ 

17 "get_class_of", 

18 "get_full_type_name", 

19 "get_instance_of", 

20 "get_caller_name", 

21 "find_outside_stacklevel", 

22] 

23 

24import builtins 

25import inspect 

26import sys 

27import types 

28import warnings 

29from collections.abc import Set 

30from typing import Any 

31 

32from .doImport import doImport, doImportType 

33 

34 

35def get_full_type_name(cls_: Any) -> str: 

36 """Return full type name of the supplied entity. 

37 

38 Parameters 

39 ---------- 

40 cls_ : `type` or `object` 

41 Entity from which to obtain the full name. Can be an instance 

42 or a `type`. 

43 

44 Returns 

45 ------- 

46 name : `str` 

47 Full name of type. 

48 

49 Notes 

50 ----- 

51 Builtins are returned without the ``builtins`` specifier included. This 

52 allows `str` to be returned as "str" rather than "builtins.str". Any 

53 parts of the path that start with a leading underscore are removed 

54 on the assumption that they are an implementation detail and the 

55 entity will be hoisted into the parent namespace. 

56 """ 

57 # If we have a module that needs to be converted directly 

58 # to a name. 

59 if isinstance(cls_, types.ModuleType): 

60 return cls_.__name__ 

61 # If we have an instance we need to convert to a type 

62 if not hasattr(cls_, "__qualname__"): 

63 cls_ = type(cls_) 

64 if hasattr(builtins, cls_.__qualname__): 

65 # Special case builtins such as str and dict 

66 return cls_.__qualname__ 

67 

68 real_name = cls_.__module__ + "." + cls_.__qualname__ 

69 

70 # Remove components with leading underscores 

71 cleaned_name = ".".join(c for c in real_name.split(".") if not c.startswith("_")) 

72 

73 # Consistency check 

74 if real_name != cleaned_name: 

75 try: 

76 test = doImport(cleaned_name) 

77 except Exception: 

78 # Could not import anything so return the real name 

79 return real_name 

80 

81 # The thing we imported should match the class we started with 

82 # despite the clean up. If it does not we return the real name 

83 if test is not cls_: 

84 return real_name 

85 

86 return cleaned_name 

87 

88 

89def get_class_of(typeOrName: type | str | types.ModuleType) -> type: 

90 """Given the type name or a type, return the python type. 

91 

92 If a type name is given, an attempt will be made to import the type. 

93 

94 Parameters 

95 ---------- 

96 typeOrName : `str` or Python class 

97 A string describing the Python class to load or a Python type. 

98 

99 Returns 

100 ------- 

101 type_ : `type` 

102 Directly returns the Python type if a type was provided, else 

103 tries to import the given string and returns the resulting type. 

104 

105 Notes 

106 ----- 

107 This is a thin wrapper around `~lsst.utils.doImport`. 

108 

109 Raises 

110 ------ 

111 TypeError 

112 Raised if a module is imported rather than a type. 

113 """ 

114 if isinstance(typeOrName, str): 

115 cls = doImportType(typeOrName) 

116 else: 

117 if isinstance(typeOrName, types.ModuleType): 

118 raise TypeError(f"Can not get class of module {get_full_type_name(typeOrName)}") 

119 cls = typeOrName 

120 return cls 

121 

122 

123def get_instance_of(typeOrName: type | str, *args: Any, **kwargs: Any) -> Any: 

124 """Given the type name or a type, instantiate an object of that type. 

125 

126 If a type name is given, an attempt will be made to import the type. 

127 

128 Parameters 

129 ---------- 

130 typeOrName : `str` or Python class 

131 A string describing the Python class to load or a Python type. 

132 *args : `tuple` 

133 Positional arguments to use pass to the object constructor. 

134 **kwargs 

135 Keyword arguments to pass to object constructor. 

136 

137 Returns 

138 ------- 

139 instance : `object` 

140 Instance of the requested type, instantiated with the provided 

141 parameters. 

142 

143 Raises 

144 ------ 

145 TypeError 

146 Raised if a module is imported rather than a type. 

147 """ 

148 cls = get_class_of(typeOrName) 

149 return cls(*args, **kwargs) 

150 

151 

152def get_caller_name(stacklevel: int = 2) -> str: 

153 """Get the name of the caller method. 

154 

155 Any item that cannot be determined (or is not relevant, e.g. a free 

156 function has no class) is silently omitted, along with an 

157 associated separator. 

158 

159 Parameters 

160 ---------- 

161 stacklevel : `int` 

162 How many levels of stack to skip while getting caller name; 

163 1 means "who calls me", 2 means "who calls my caller", etc. 

164 

165 Returns 

166 ------- 

167 name : `str` 

168 Name of the caller as a string in the form ``module.class.method``. 

169 An empty string is returned if ``stacklevel`` exceeds the stack height. 

170 

171 Notes 

172 ----- 

173 Adapted from http://stackoverflow.com/a/9812105 

174 by adding support to get the class from ``parentframe.f_locals['cls']`` 

175 """ 

176 stack = inspect.stack() 

177 start = 0 + stacklevel 

178 if len(stack) < start + 1: 

179 return "" 

180 parentframe = stack[start][0] 

181 

182 name = [] 

183 module = inspect.getmodule(parentframe) 

184 if module: 

185 name.append(module.__name__) 

186 # add class name, if any 

187 if "self" in parentframe.f_locals: 

188 name.append(type(parentframe.f_locals["self"]).__name__) 

189 elif "cls" in parentframe.f_locals: 

190 name.append(parentframe.f_locals["cls"].__name__) 

191 codename = parentframe.f_code.co_name 

192 if codename != "<module>": # top level usually 

193 name.append(codename) # function or a method 

194 return ".".join(name) 

195 

196 

197def find_outside_stacklevel( 

198 *module_names: str, 

199 allow_modules: Set[str] = frozenset(), 

200 allow_methods: Set[str] = frozenset(), 

201 stack_info: dict[str, Any] | None = None, 

202) -> int: 

203 """Find the stacklevel for outside of the given module. 

204 

205 This can be used to determine the stacklevel parameter that should be 

206 passed to log messages or warnings in order to make them appear to 

207 come from external code and not this package. 

208 

209 Parameters 

210 ---------- 

211 *module_names : `str` 

212 The names of the modules to skip when calculating the relevant stack 

213 level. 

214 allow_modules : `set` [`str`] 

215 Names that should not be skipped when calculating the stacklevel. 

216 If the module name starts with any of the names in this set the 

217 corresponding stacklevel is used. 

218 allow_methods : `set` [`str`] 

219 Method names that are allowed to be treated as "outside". Fully 

220 qualified method names must match exactly. Method names without 

221 path components will match solely the method name itself. On Python 

222 3.10 fully qualified names are not supported. 

223 stack_info : `dict` or `None`, optional 

224 If given, the dictionary is filled with information from 

225 the relevant stack frame. This can be used to form your own warning 

226 message without having to call :func:`inspect.stack` yourself with 

227 the stack level. 

228 

229 Returns 

230 ------- 

231 stacklevel : `int` 

232 The stacklevel to use matching the first stack frame outside of the 

233 given module. 

234 

235 Examples 

236 -------- 

237 .. code-block :: python 

238 

239 warnings.warn( 

240 "A warning message", 

241 stacklevel=find_outside_stacklevel("lsst.daf") 

242 ) 

243 """ 

244 if sys.version_info < (3, 11, 0): 

245 short_names = {m for m in allow_methods if "." not in m} 

246 if len(short_names) != len(allow_methods): 

247 warnings.warn( 

248 "Python 3.10 does not support fully qualified names in allow_methods. Dropping them.", 

249 stacklevel=2, 

250 ) 

251 allow_methods = short_names 

252 

253 need_full_names = any("." in m for m in allow_methods) 

254 

255 if stack_info is not None: 

256 # Ensure it is empty when we start. 

257 stack_info.clear() 

258 

259 stacklevel = -1 

260 for i, s in enumerate(inspect.stack()): 

261 # This function is never going to be the right answer. 

262 if i == 0: 

263 continue 

264 module = inspect.getmodule(s.frame) 

265 if module is None: 

266 continue 

267 

268 if stack_info is not None: 

269 stack_info["filename"] = s.filename 

270 stack_info["lineno"] = s.lineno 

271 stack_info["name"] = s.frame.f_code.co_name 

272 

273 if allow_methods: 

274 code = s.frame.f_code 

275 names = {code.co_name} # The name of the function itself. 

276 if need_full_names: 

277 full_name = f"{module.__name__}.{code.co_qualname}" 

278 names.add(full_name) 

279 if names & allow_methods: 

280 # Method name is allowed so we stop here. 

281 del s 

282 stacklevel = i 

283 break 

284 

285 # Stack frames sometimes hang around so explicitly delete. 

286 del s 

287 

288 if ( 

289 # The module does not match any of the skipped names. 

290 not any(module.__name__.startswith(name) for name in module_names) 

291 # This match is explicitly allowed to be treated as "outside". 

292 or any(module.__name__.startswith(name) for name in allow_modules) 

293 ): 

294 # 0 will be this function. 

295 # 1 will be the caller 

296 # and so does not need adjustment. 

297 stacklevel = i 

298 break 

299 else: 

300 # The top can't be inside the module. 

301 stacklevel = i 

302 

303 return stacklevel