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

168 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-15 16:47 +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 Any, ClassVar, Sequence 

9 

10from _griffe.enumerations import ObjectKind 

11from _griffe.logger import logger 

12 

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

14_cyclic_relationships = { 

15 ("os", "nt"), 

16 ("os", "posix"), 

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

18} 

19 

20 

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

22 # YORE: EOL 3.8: Replace `lstrip` with `removeprefix` within line. 

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

24 

25 

26class ObjectNode: 

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

28 

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

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

31 

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

33 """ 

34 

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

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

37 

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

39 """Initialize the object. 

40 

41 Parameters: 

42 obj: A Python object. 

43 name: The object's name. 

44 parent: The object's parent node. 

45 """ 

46 # Unwrap object. 

47 try: 

48 obj = inspect.unwrap(obj) 

49 except Exception as error: # noqa: BLE001 

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

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

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

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

54 logger.debug(f"Could not unwrap {name}: {error!r}") 

55 

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

57 if isinstance(obj, cached_property): 

58 is_cached_property = True 

59 obj = obj.func 

60 else: 

61 is_cached_property = False 

62 

63 self.obj: Any = obj 

64 """The actual Python object.""" 

65 self.name: str = name 

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

67 self.parent: ObjectNode | None = parent 

68 """The parent node.""" 

69 self.is_cached_property: bool = is_cached_property 

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

71 

72 def __repr__(self) -> str: 

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

74 

75 @property 

76 def path(self) -> str: 

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

78 if self.parent is None: 

79 return self.name 

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

81 

82 @property 

83 def module(self) -> ObjectNode: 

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

85 if self.is_module: 

86 return self 

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

88 return self.parent.module 

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

90 

91 @property 

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

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

94 try: 

95 return self.obj.__module__ 

96 except AttributeError: 

97 try: 

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

99 except ValueError: 

100 return None 

101 try: 

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

103 except AttributeError: 

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

105 

106 @property 

107 def kind(self) -> ObjectKind: 

108 """The kind of this node.""" 

109 if self.is_module: 

110 return ObjectKind.MODULE 

111 if self.is_class: 

112 return ObjectKind.CLASS 

113 if self.is_staticmethod: 

114 return ObjectKind.STATICMETHOD 

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

116 return ObjectKind.CLASSMETHOD 

117 if self.is_cached_property: 

118 return ObjectKind.CACHED_PROPERTY 

119 if self.is_method: 

120 return ObjectKind.METHOD 

121 if self.is_builtin_method: 

122 return ObjectKind.BUILTIN_METHOD 

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

124 return ObjectKind.COROUTINE 

125 if self.is_builtin_function: 

126 return ObjectKind.BUILTIN_FUNCTION 

127 if self.is_method_descriptor: 

128 return ObjectKind.METHOD_DESCRIPTOR 

129 if self.is_function: 

130 return ObjectKind.FUNCTION 

131 if self.is_property: 

132 return ObjectKind.PROPERTY 

133 return ObjectKind.ATTRIBUTE 

134 

135 @cached_property 

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

137 """The children of this node.""" 

138 children = [] 

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

140 if self._pick_member(name, member): 

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

142 return children 

143 

144 @cached_property 

145 def is_module(self) -> bool: 

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

147 return inspect.ismodule(self.obj) 

148 

149 @cached_property 

150 def is_class(self) -> bool: 

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

152 return inspect.isclass(self.obj) 

153 

154 @cached_property 

155 def is_function(self) -> bool: 

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

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

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

159 

160 @cached_property 

161 def is_builtin_function(self) -> bool: 

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

163 return inspect.isbuiltin(self.obj) 

164 

165 @cached_property 

166 def is_coroutine(self) -> bool: 

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

168 return inspect.iscoroutinefunction(self.obj) 

169 

170 @cached_property 

171 def is_property(self) -> bool: 

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

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

174 

175 @cached_property 

176 def parent_is_class(self) -> bool: 

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

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

179 

180 @cached_property 

181 def is_method(self) -> bool: 

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

183 function_type = type(lambda: None) 183 ↛ exitline 183 didn't run the lambda on line 183

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

185 

186 @cached_property 

187 def is_method_descriptor(self) -> bool: 

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

189 

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

191 method descriptors, rather than normal methods. 

192 """ 

193 return inspect.ismethoddescriptor(self.obj) 

194 

195 @cached_property 

196 def is_builtin_method(self) -> bool: 

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

198 return self.is_builtin_function and self.parent_is_class 

199 

200 @cached_property 

201 def is_staticmethod(self) -> bool: 

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

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

204 return False 

205 try: 

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

207 except AttributeError: 

208 return False 

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

210 

211 @cached_property 

212 def is_classmethod(self) -> bool: 

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

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

215 return False 

216 try: 

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

218 except AttributeError: 

219 return False 

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

221 

222 @cached_property 

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

224 if self.parent is None: 

225 return {id(self.obj)} 

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

227 

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

229 return ( 

230 name not in self.exclude_specials 

231 and member is not type 

232 and member is not object 

233 and id(member) not in self._ids 

234 and name in vars(self.obj) 

235 ) 

236 

237 @cached_property 

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

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

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

241 return None 

242 

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

244 child_module_path = self.module_path 

245 if not child_module_path: 

246 return None 

247 

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

249 parent_module_path = self.parent.module_path 

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

251 return None 

252 

253 # Special cases: break cycles. 

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

255 return None 

256 

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

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

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

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

261 if _same_components(parent_module_path, child_module_path): 

262 return None 

263 

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

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

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

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

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

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

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

271 child_module_path = child_module_path.lstrip("_") 

272 if self.is_module: 

273 return child_module_path 

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

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