Coverage for src/_griffe/agents/nodes/runtime.py: 87.95%

168 statements  

« prev     ^ index     » next       coverage.py v7.6.2, created at 2024-10-12 01:34 +0200

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

2 

3from __future__ import annotations 

4 

5import inspect 

6import sys 

7from functools import cached_property 

8from typing import TYPE_CHECKING, Any, ClassVar 

9 

10from _griffe.enumerations import ObjectKind 

11from _griffe.logger import logger 

12 

13if TYPE_CHECKING: 

14 from collections.abc import Sequence 

15 

16_builtin_module_names = {_.lstrip("_") for _ in sys.builtin_module_names} 

17_cyclic_relationships = { 

18 ("os", "nt"), 

19 ("os", "posix"), 

20 ("numpy.core._multiarray_umath", "numpy.core.multiarray"), 

21} 

22 

23 

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

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

26 

27 

28class ObjectNode: 

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

30 

31 It's not really a tree but more a backward-linked list: 

32 each node has a reference to its parent, but not to its child (for simplicity purposes and to avoid bugs). 

33 

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

35 """ 

36 

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

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

39 

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

41 """Initialize the object. 

42 

43 Parameters: 

44 obj: A Python object. 

45 name: The object's name. 

46 parent: The object's parent node. 

47 """ 

48 # Unwrap object. 

49 try: 

50 obj = inspect.unwrap(obj) 

51 except Exception as error: # noqa: BLE001 

52 # inspect.unwrap at some point runs hasattr(obj, "__wrapped__"), 

53 # which triggers the __getattr__ method of the object, which in 

54 # turn can raise various exceptions. Probably not just __getattr__. 

55 # See https://github.com/pawamoy/pytkdocs/issues/45 

56 logger.debug("Could not unwrap %s: %r", name, error) 

57 

58 # Unwrap cached properties (`inspect.unwrap` doesn't do that). 

59 if isinstance(obj, cached_property): 

60 is_cached_property = True 

61 obj = obj.func 

62 else: 

63 is_cached_property = False 

64 

65 self.obj: Any = obj 

66 """The actual Python object.""" 

67 self.name: str = name 

68 """The Python object's name.""" 

69 self.parent: ObjectNode | None = parent 

70 """The parent node.""" 

71 self.is_cached_property: bool = is_cached_property 

72 """Whether this node's object is a cached property.""" 

73 

74 def __repr__(self) -> str: 

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

76 

77 @property 

78 def path(self) -> str: 

79 """The object's (Python) path.""" 

80 if self.parent is None: 

81 return self.name 

82 return f"{self.parent.path}.{self.name}" 

83 

84 @property 

85 def module(self) -> ObjectNode: 

86 """The object's module, fetched from the node tree.""" 

87 if self.is_module: 

88 return self 

89 if self.parent is not None: 89 ↛ 91line 89 didn't jump to line 91 because the condition on line 89 was always true

90 return self.parent.module 

91 raise ValueError(f"Object node {self.path} does not have a parent module") 

92 

93 @property 

94 def module_path(self) -> str | None: 

95 """The object's module path.""" 

96 try: 

97 return self.obj.__module__ 

98 except AttributeError: 

99 try: 

100 module = inspect.getmodule(self.obj) or self.module.obj 

101 except ValueError: 

102 return None 

103 try: 

104 return module.__spec__.name # type: ignore[union-attr] 

105 except AttributeError: 

106 return getattr(module, "__name__", None) 

107 

108 @property 

109 def kind(self) -> ObjectKind: 

110 """The kind of this node.""" 

111 if self.is_module: 

112 return ObjectKind.MODULE 

113 if self.is_class: 

114 return ObjectKind.CLASS 

115 if self.is_staticmethod: 

116 return ObjectKind.STATICMETHOD 

117 if self.is_classmethod: 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true

118 return ObjectKind.CLASSMETHOD 

119 if self.is_cached_property: 

120 return ObjectKind.CACHED_PROPERTY 

121 if self.is_method: 

122 return ObjectKind.METHOD 

123 if self.is_builtin_method: 

124 return ObjectKind.BUILTIN_METHOD 

125 if self.is_coroutine: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true

126 return ObjectKind.COROUTINE 

127 if self.is_builtin_function: 

128 return ObjectKind.BUILTIN_FUNCTION 

129 if self.is_method_descriptor: 

130 return ObjectKind.METHOD_DESCRIPTOR 

131 if self.is_function: 

132 return ObjectKind.FUNCTION 

133 if self.is_property: 

134 return ObjectKind.PROPERTY 

135 return ObjectKind.ATTRIBUTE 

136 

137 @cached_property 

138 def children(self) -> Sequence[ObjectNode]: 

139 """The children of this node.""" 

140 children = [] 

141 for name, member in inspect.getmembers(self.obj): 

142 if self._pick_member(name, member): 

143 children.append(ObjectNode(member, name, parent=self)) 

144 return children 

145 

146 @cached_property 

147 def is_module(self) -> bool: 

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

149 return inspect.ismodule(self.obj) 

150 

151 @cached_property 

152 def is_class(self) -> bool: 

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

154 return inspect.isclass(self.obj) 

155 

156 @cached_property 

157 def is_function(self) -> bool: 

158 """Whether this node's object is a function.""" 

159 # `inspect.isfunction` returns `False` for partials. 

160 return inspect.isfunction(self.obj) or (callable(self.obj) and not self.is_class) 

161 

162 @cached_property 

163 def is_builtin_function(self) -> bool: 

164 """Whether this node's object is a builtin function.""" 

165 return inspect.isbuiltin(self.obj) 

166 

167 @cached_property 

168 def is_coroutine(self) -> bool: 

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

170 return inspect.iscoroutinefunction(self.obj) 

171 

172 @cached_property 

173 def is_property(self) -> bool: 

174 """Whether this node's object is a property.""" 

175 return isinstance(self.obj, property) or self.is_cached_property 

176 

177 @cached_property 

178 def parent_is_class(self) -> bool: 

179 """Whether the object of this node's parent is a class.""" 

180 return bool(self.parent and self.parent.is_class) 

181 

182 @cached_property 

183 def is_method(self) -> bool: 

184 """Whether this node's object is a method.""" 

185 function_type = type(lambda: None) 

186 return self.parent_is_class and isinstance(self.obj, function_type) 

187 

188 @cached_property 

189 def is_method_descriptor(self) -> bool: 

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

191 

192 Built-in methods (e.g. those implemented in C/Rust) are often 

193 method descriptors, rather than normal methods. 

194 """ 

195 return inspect.ismethoddescriptor(self.obj) 

196 

197 @cached_property 

198 def is_builtin_method(self) -> bool: 

199 """Whether this node's object is a builtin method.""" 

200 return self.is_builtin_function and self.parent_is_class 

201 

202 @cached_property 

203 def is_staticmethod(self) -> bool: 

204 """Whether this node's object is a staticmethod.""" 

205 if self.parent is None: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 return False 

207 try: 

208 self_from_parent = self.parent.obj.__dict__.get(self.name, None) 

209 except AttributeError: 

210 return False 

211 return self.parent_is_class and isinstance(self_from_parent, staticmethod) 

212 

213 @cached_property 

214 def is_classmethod(self) -> bool: 

215 """Whether this node's object is a classmethod.""" 

216 if self.parent is None: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true

217 return False 

218 try: 

219 self_from_parent = self.parent.obj.__dict__.get(self.name, None) 

220 except AttributeError: 

221 return False 

222 return self.parent_is_class and isinstance(self_from_parent, classmethod) 

223 

224 @cached_property 

225 def _ids(self) -> set[int]: 

226 if self.parent is None: 

227 return {id(self.obj)} 

228 return {id(self.obj)} | self.parent._ids 

229 

230 def _pick_member(self, name: str, member: Any) -> bool: 

231 return ( 

232 name not in self.exclude_specials 

233 and member is not type 

234 and member is not object 

235 and id(member) not in self._ids 

236 and name in vars(self.obj) 

237 ) 

238 

239 @cached_property 

240 def alias_target_path(self) -> str | None: 

241 """Alias target path of this node, if the node should be an alias.""" 

242 if self.parent is None: 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true

243 return None 

244 

245 # Get the path of the module the child was declared in. 

246 child_module_path = self.module_path 

247 if not child_module_path: 

248 return None 

249 

250 # Get the module the parent object was declared in. 

251 parent_module_path = self.parent.module_path 

252 if not parent_module_path: 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true

253 return None 

254 

255 # Special cases: break cycles. 

256 if (parent_module_path, child_module_path) in _cyclic_relationships: 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true

257 return None 

258 

259 # If the current object was declared in the same module as its parent, 

260 # or in a module with the same path components but starting/not starting with underscores, 

261 # we don't want to alias it. Examples: (a, a), (a, _a), (_a, a), (_a, _a), 

262 # (a.b, a.b), (a.b, _a.b), (_a._b, a.b), (a._b, _a.b), etc.. 

263 if _same_components(parent_module_path, child_module_path): 

264 return None 

265 

266 # If the current object was declared in any other module, we alias it. 

267 # We remove the leading underscore from the child module path 

268 # if it's a built-in module (e.g. _io -> io). That's because objects 

269 # in built-in modules inconsistently lie about their module path, 

270 # so we prefer to use the non-underscored (public) version, 

271 # as users most likely import from the public module and not the private one. 

272 if child_module_path.lstrip("_") in _builtin_module_names: 

273 child_module_path = child_module_path.lstrip("_") 

274 if self.is_module: 

275 return child_module_path 

276 child_name = getattr(self.obj, "__qualname__", self.path[len(self.module.path) + 1 :]) 

277 return f"{child_module_path}.{child_name}"