Coverage for src/griffe/_internal/agents/nodes/runtime.py: 88.54%
191 statements
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-11 13:44 +0200
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-11 13:44 +0200
1# This module contains utilities for extracting information from runtime objects.
3from __future__ import annotations
5import inspect
6import sys
7import typing
8from functools import cached_property
9from types import GetSetDescriptorType
10from typing import TYPE_CHECKING, Any, ClassVar
12from griffe._internal.enumerations import ObjectKind
13from griffe._internal.logger import logger
15if TYPE_CHECKING:
16 import types
17 from collections.abc import Sequence
19_TYPING_MODULES: tuple[types.ModuleType, ...]
20try:
21 import typing_extensions
22except ImportError:
23 _TYPING_MODULES = (typing,)
24else:
25 _TYPING_MODULES = (typing, typing_extensions)
28_builtin_module_names = {_.lstrip("_") for _ in sys.builtin_module_names}
29_cyclic_relationships = {
30 ("os", "nt"),
31 ("os", "posix"),
32 ("numpy.core._multiarray_umath", "numpy.core.multiarray"),
33}
36def _same_components(a: str, b: str, /) -> bool:
37 return [cpn.lstrip("_") for cpn in a.split(".")] == [cpn.lstrip("_") for cpn in b.split(".")]
40class ObjectNode:
41 """Helper class to represent an object tree.
43 It's not really a tree but more a backward-linked list:
44 each node has a reference to its parent, but not to its child (for simplicity purposes and to avoid bugs).
46 Each node stores an object, its name, and a reference to its parent node.
47 """
49 exclude_specials: ClassVar[set[str]] = {"__builtins__", "__loader__", "__spec__"}
50 """Low level attributes known to cause issues when resolving aliases."""
52 def __init__(self, obj: Any, name: str, parent: ObjectNode | None = None) -> None:
53 """Initialize the object.
55 Parameters:
56 obj: A Python object.
57 name: The object's name.
58 parent: The object's parent node.
59 """
60 # Unwrap object.
61 try:
62 obj = inspect.unwrap(obj)
63 except Exception as error: # noqa: BLE001
64 # `inspect.unwrap` at some point runs `hasattr(obj, "__wrapped__")`,
65 # which triggers the `__getattr__` method of the object, which in
66 # turn can raise various exceptions. Probably not just `__getattr__`.
67 # See https://github.com/pawamoy/pytkdocs/issues/45.
68 logger.debug("Could not unwrap %s: %r", name, error)
70 # Unwrap cached properties (`inspect.unwrap` doesn't do that).
71 if isinstance(obj, cached_property):
72 is_cached_property = True
73 obj = obj.func
74 else:
75 is_cached_property = False
77 self.obj: Any = obj
78 """The actual Python object."""
79 self.name: str = name
80 """The Python object's name."""
81 self.parent: ObjectNode | None = parent
82 """The parent node."""
83 self.is_cached_property: bool = is_cached_property
84 """Whether this node's object is a cached property."""
86 def __repr__(self) -> str:
87 return f"ObjectNode(name={self.name!r})"
89 @property
90 def path(self) -> str:
91 """The object's (Python) path."""
92 if self.parent is None:
93 return self.name
94 return f"{self.parent.path}.{self.name}"
96 @property
97 def module(self) -> ObjectNode:
98 """The object's module, fetched from the node tree."""
99 if self.is_module:
100 return self
101 if self.parent is not None: 101 ↛ 103line 101 didn't jump to line 103 because the condition on line 101 was always true
102 return self.parent.module
103 raise ValueError(f"Object node {self.path} does not have a parent module")
105 @property
106 def module_path(self) -> str | None:
107 """The object's module path."""
108 try:
109 return self.obj.__module__
110 except AttributeError:
111 try:
112 module = inspect.getmodule(self.obj) or self.module.obj
113 except ValueError:
114 return None
115 try:
116 return module.__spec__.name # type: ignore[union-attr]
117 except AttributeError:
118 return getattr(module, "__name__", None)
120 @property
121 def kind(self) -> ObjectKind:
122 """The kind of this node."""
123 if self.is_module:
124 return ObjectKind.MODULE
125 if self.is_class:
126 return ObjectKind.CLASS
127 if self.is_staticmethod:
128 return ObjectKind.STATICMETHOD
129 if self.is_classmethod: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 return ObjectKind.CLASSMETHOD
131 if self.is_cached_property:
132 return ObjectKind.CACHED_PROPERTY
133 if self.is_method:
134 return ObjectKind.METHOD
135 if self.is_builtin_method:
136 return ObjectKind.BUILTIN_METHOD
137 if self.is_coroutine: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
138 return ObjectKind.COROUTINE
139 if self.is_builtin_function:
140 return ObjectKind.BUILTIN_FUNCTION
141 if self.is_method_descriptor:
142 return ObjectKind.METHOD_DESCRIPTOR
143 if self.is_function:
144 return ObjectKind.FUNCTION
145 if self.is_getset_descriptor:
146 return ObjectKind.GETSET_DESCRIPTOR
147 if self.is_property:
148 return ObjectKind.PROPERTY
149 if self.is_type_alias:
150 return ObjectKind.TYPE_ALIAS
151 return ObjectKind.ATTRIBUTE
153 @cached_property
154 def children(self) -> Sequence[ObjectNode]:
155 """The children of this node."""
156 children = []
157 for name, member in inspect.getmembers(self.obj):
158 if self._pick_member(name, member):
159 children.append(ObjectNode(member, name, parent=self))
160 return children
162 @cached_property
163 def is_module(self) -> bool:
164 """Whether this node's object is a module."""
165 return inspect.ismodule(self.obj)
167 @cached_property
168 def is_class(self) -> bool:
169 """Whether this node's object is a class."""
170 return inspect.isclass(self.obj)
172 @cached_property
173 def is_function(self) -> bool:
174 """Whether this node's object is a function."""
175 # `inspect.isfunction` returns `False` for partials.
176 return inspect.isfunction(self.obj) or (callable(self.obj) and not self.is_class)
178 @cached_property
179 def is_type_alias(self) -> bool:
180 """Whether this node's object is a type alias."""
181 return isinstance(
182 self.obj,
183 tuple(module.TypeAliasType for module in _TYPING_MODULES if hasattr(module, "TypeAliasType")),
184 )
186 @cached_property
187 def is_builtin_function(self) -> bool:
188 """Whether this node's object is a builtin function."""
189 return inspect.isbuiltin(self.obj)
191 @cached_property
192 def is_coroutine(self) -> bool:
193 """Whether this node's object is a coroutine."""
194 return inspect.iscoroutinefunction(self.obj)
196 @cached_property
197 def is_getset_descriptor(self) -> bool:
198 """Whether this node's object is a get/set descriptor."""
199 return isinstance(self.obj, GetSetDescriptorType)
201 @cached_property
202 def is_property(self) -> bool:
203 """Whether this node's object is a property."""
204 return isinstance(self.obj, property) or self.is_cached_property
206 @cached_property
207 def parent_is_class(self) -> bool:
208 """Whether the object of this node's parent is a class."""
209 return bool(self.parent and self.parent.is_class)
211 @cached_property
212 def is_method(self) -> bool:
213 """Whether this node's object is a method."""
214 function_type = type(lambda: None)
215 return self.parent_is_class and isinstance(self.obj, function_type)
217 @cached_property
218 def is_method_descriptor(self) -> bool:
219 """Whether this node's object is a method descriptor.
221 Built-in methods (e.g. those implemented in C/Rust) are often
222 method descriptors, rather than normal methods.
223 """
224 return inspect.ismethoddescriptor(self.obj)
226 @cached_property
227 def is_builtin_method(self) -> bool:
228 """Whether this node's object is a builtin method."""
229 return self.is_builtin_function and self.parent_is_class
231 @cached_property
232 def is_staticmethod(self) -> bool:
233 """Whether this node's object is a staticmethod."""
234 if self.parent is None: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 return False
236 try:
237 self_from_parent = self.parent.obj.__dict__.get(self.name, None)
238 except AttributeError:
239 return False
240 return self.parent_is_class and isinstance(self_from_parent, staticmethod)
242 @cached_property
243 def is_classmethod(self) -> bool:
244 """Whether this node's object is a classmethod."""
245 if self.parent is None: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 return False
247 try:
248 self_from_parent = self.parent.obj.__dict__.get(self.name, None)
249 except AttributeError:
250 return False
251 return self.parent_is_class and isinstance(self_from_parent, classmethod)
253 @cached_property
254 def is_attribute(self) -> bool:
255 """Whether this node's object is an attribute."""
256 return self.kind is ObjectKind.ATTRIBUTE
258 @cached_property
259 def _ids(self) -> set[int]:
260 if self.parent is None:
261 return {id(self.obj)}
262 return {id(self.obj)} | self.parent._ids
264 def _pick_member(self, name: str, member: Any) -> bool:
265 return (
266 name not in self.exclude_specials
267 and member is not type
268 and member is not object
269 and id(member) not in self._ids
270 and name in vars(self.obj)
271 )
273 @cached_property
274 def alias_target_path(self) -> str | None:
275 """Alias target path of this node, if the node should be an alias."""
276 # Top-level objects can't have been imported.
277 if self.parent is None: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 return None
280 # We can't ever know if an attribute was imported.
281 if self.is_attribute:
282 return None
284 # Get the path of the module the child object was declared in.
285 child_module_path = self.module_path
286 if not child_module_path:
287 return None
289 # Get the path of the module the parent object was declared in.
290 parent_module_path = self.parent.module_path
291 if not parent_module_path: 291 ↛ 292line 291 didn't jump to line 292 because the condition on line 291 was never true
292 return None
294 # Special cases: break cycles.
295 if (parent_module_path, child_module_path) in _cyclic_relationships: 295 ↛ 296line 295 didn't jump to line 296 because the condition on line 295 was never true
296 return None
298 # If the current object was declared in the same module as its parent,
299 # or in a module with the same path components but starting/not starting with underscores,
300 # we don't want to alias it. Examples: (a, a), (a, _a), (_a, a), (_a, _a),
301 # (a.b, a.b), (a.b, _a.b), (_a._b, a.b), (a._b, _a.b), etc..
302 if _same_components(parent_module_path, child_module_path):
303 return None
305 # If the current object was declared in any other module, we alias it.
306 # We remove the leading underscore from the child module path
307 # if it's a built-in module (e.g. _io -> io). That's because objects
308 # in built-in modules inconsistently lie about their module path,
309 # so we prefer to use the non-underscored (public) version,
310 # as users most likely import from the public module and not the private one.
311 if child_module_path.lstrip("_") in _builtin_module_names:
312 child_module_path = child_module_path.lstrip("_")
314 # Child object is a module, return its path directly.
315 if self.is_module:
316 return child_module_path
318 # Rebuild the child object path.
319 child_name = getattr(self.obj, "__qualname__", self.path[len(self.module.path) + 1 :])
320 return f"{child_module_path}.{child_name}"