Coverage for python/lsst/utils/introspection.py: 11%
92 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-06 03:35 -0700
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-06 03:35 -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."""
14from __future__ import annotations
16__all__ = [
17 "get_class_of",
18 "get_full_type_name",
19 "get_instance_of",
20 "get_caller_name",
21 "find_outside_stacklevel",
22]
24import builtins
25import inspect
26import sys
27import types
28import warnings
29from collections.abc import Set
30from typing import Any
32from .doImport import doImport, doImportType
35def get_full_type_name(cls_: Any) -> str:
36 """Return full type name of the supplied entity.
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`.
44 Returns
45 -------
46 name : `str`
47 Full name of type.
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__
68 real_name = cls_.__module__ + "." + cls_.__qualname__
70 # Remove components with leading underscores
71 cleaned_name = ".".join(c for c in real_name.split(".") if not c.startswith("_"))
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
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
86 return cleaned_name
89def get_class_of(typeOrName: type | str | types.ModuleType) -> type:
90 """Given the type name or a type, return the python type.
92 If a type name is given, an attempt will be made to import the type.
94 Parameters
95 ----------
96 typeOrName : `str` or Python class
97 A string describing the Python class to load or a Python type.
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.
105 Notes
106 -----
107 This is a thin wrapper around `~lsst.utils.doImport`.
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
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.
126 If a type name is given, an attempt will be made to import the type.
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.
137 Returns
138 -------
139 instance : `object`
140 Instance of the requested type, instantiated with the provided
141 parameters.
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)
152def get_caller_name(stacklevel: int = 2) -> str:
153 """Get the name of the caller method.
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.
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.
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.
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]
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)
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.
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.
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.
229 Returns
230 -------
231 stacklevel : `int`
232 The stacklevel to use matching the first stack frame outside of the
233 given module.
235 Examples
236 --------
237 .. code-block :: python
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
253 need_full_names = any("." in m for m in allow_methods)
255 if stack_info is not None:
256 # Ensure it is empty when we start.
257 stack_info.clear()
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
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
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
285 # Stack frames sometimes hang around so explicitly delete.
286 del s
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
303 return stacklevel