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

1# This module contains utilities for extracting information from runtime objects. 

2 

3from __future__ import annotations 

4 

5import inspect 

6import sys 

7import typing 

8from functools import cached_property 

9from types import GetSetDescriptorType 

10from typing import TYPE_CHECKING, Any, ClassVar 

11 

12from griffe._internal.enumerations import ObjectKind 

13from griffe._internal.logger import logger 

14 

15if TYPE_CHECKING: 

16 import types 

17 from collections.abc import Sequence 

18 

19_TYPING_MODULES: tuple[types.ModuleType, ...] 

20try: 

21 import typing_extensions 

22except ImportError: 

23 _TYPING_MODULES = (typing,) 

24else: 

25 _TYPING_MODULES = (typing, typing_extensions) 

26 

27 

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} 

34 

35 

36def _same_components(a: str, b: str, /) -> bool: 

37 return [cpn.lstrip("_") for cpn in a.split(".")] == [cpn.lstrip("_") for cpn in b.split(".")] 

38 

39 

40class ObjectNode: 

41 """Helper class to represent an object tree. 

42 

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). 

45 

46 Each node stores an object, its name, and a reference to its parent node. 

47 """ 

48 

49 exclude_specials: ClassVar[set[str]] = {"__builtins__", "__loader__", "__spec__"} 

50 """Low level attributes known to cause issues when resolving aliases.""" 

51 

52 def __init__(self, obj: Any, name: str, parent: ObjectNode | None = None) -> None: 

53 """Initialize the object. 

54 

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) 

69 

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 

76 

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.""" 

85 

86 def __repr__(self) -> str: 

87 return f"ObjectNode(name={self.name!r})" 

88 

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}" 

95 

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") 

104 

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) 

119 

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 

152 

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 

161 

162 @cached_property 

163 def is_module(self) -> bool: 

164 """Whether this node's object is a module.""" 

165 return inspect.ismodule(self.obj) 

166 

167 @cached_property 

168 def is_class(self) -> bool: 

169 """Whether this node's object is a class.""" 

170 return inspect.isclass(self.obj) 

171 

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) 

177 

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 ) 

185 

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) 

190 

191 @cached_property 

192 def is_coroutine(self) -> bool: 

193 """Whether this node's object is a coroutine.""" 

194 return inspect.iscoroutinefunction(self.obj) 

195 

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) 

200 

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 

205 

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) 

210 

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) 

216 

217 @cached_property 

218 def is_method_descriptor(self) -> bool: 

219 """Whether this node's object is a method descriptor. 

220 

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) 

225 

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 

230 

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) 

241 

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) 

252 

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 

257 

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 

263 

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 ) 

272 

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 

279 

280 # We can't ever know if an attribute was imported. 

281 if self.is_attribute: 

282 return None 

283 

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 

288 

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 

293 

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 

297 

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 

304 

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("_") 

313 

314 # Child object is a module, return its path directly. 

315 if self.is_module: 

316 return child_module_path 

317 

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}"