Coverage for src/pytkdocs/objects.py: 97.92%

162 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-09 17:28 +0100

1"""This module defines the documented objects classes. 

2 

3- the generic [`Object`][pytkdocs.objects.Object] class 

4- the [`Module`][pytkdocs.objects.Module] class 

5- the [`Class`][pytkdocs.objects.Class] class 

6- the [`Method`][pytkdocs.objects.Method] class 

7- the [`Function`][pytkdocs.objects.Function] class 

8- the [`Attribute`][pytkdocs.objects.Attribute] class 

9 

10Note that properties are considered attributes, because they are used like such. 

11 

12It also defines a convenient [`Source`][pytkdocs.objects.Source] class to represent source code. 

13""" 

14 

15import importlib 

16import inspect 

17import os 

18import sys 

19from abc import ABCMeta 

20from functools import lru_cache 

21from pathlib import Path 

22from typing import Any, Optional, Union 

23 

24from pytkdocs.parsers.docstrings.base import Parser, Section 

25from pytkdocs.properties import NAME_CLASS_PRIVATE, NAME_PRIVATE, NAME_SPECIAL, ApplicableNameProperty 

26 

27 

28class Source: 

29 """Helper class to represent source code. 

30 

31 It is simply used to wrap the result of 

32 [`inspect.getsourceslines`](https://docs.python.org/3/library/inspect.html#inspect.getsourcelines). 

33 """ 

34 

35 def __init__(self, lines: Union[str, list[str]], line_start: int) -> None: 

36 """Initialize the object. 

37 

38 Arguments: 

39 lines: A list of strings. The strings should have trailing newlines. 

40 line_start: The line number of where the code starts in the file. 

41 """ 

42 code = "".join(lines) if isinstance(lines, list) else lines 

43 self.code = code 

44 """The code, as a single string.""" 

45 self.line_start = line_start 

46 """The first line number.""" 

47 

48 

49class Object(metaclass=ABCMeta): # noqa: B024 

50 """A base class to store information about a Python object. 

51 

52 Each instance additionally stores references to its children, grouped by category. 

53 """ 

54 

55 possible_name_properties: list[ApplicableNameProperty] = [] # noqa: RUF012 

56 """ 

57 The properties that we can apply to the object based on its name. 

58 

59 The applicable properties vary from one subclass of `Object` to another. 

60 """ 

61 

62 def __init__( 

63 self, 

64 name: str, 

65 path: str, 

66 file_path: str, 

67 docstring: Optional[str] = "", 

68 properties: Optional[list[str]] = None, 

69 source: Optional[Source] = None, 

70 ) -> None: 

71 """Initialize the object. 

72 

73 Arguments: 

74 name: The object's name. 

75 path: The object's dotted-path. 

76 file_path: The file path of the object's direct parent module. 

77 docstring: The object's docstring. 

78 properties: The object's properties. 

79 source: The object's source code. 

80 """ 

81 self.name = name 

82 """The object's name.""" 

83 self.path = path 

84 """The object's dotted-path.""" 

85 self.file_path = file_path 

86 """The file path of the object's direct parent module.""" 

87 self.docstring = docstring 

88 """The object's docstring.""" 

89 self.docstring_sections: list[Section] = [] 

90 """The object's docstring parsed into sections.""" 

91 self.docstring_errors: list[str] = [] 

92 """The errors detected while parsing the docstring.""" 

93 self.properties = properties or [] 

94 """The object's properties.""" 

95 self.parent: Optional[Object] = None 

96 """The object's parent (another instance of a subclass of `Object`).""" 

97 self.source = source 

98 """The object's source code.""" 

99 

100 self._path_map = {self.path: self} 

101 self._parsed = False 

102 

103 self.attributes: list[Attribute] = [] 

104 """The list of all the object's attributes.""" 

105 self.methods: list[Method] = [] 

106 """The list of all the object's methods.""" 

107 self.functions: list[Function] = [] 

108 """The list of all the object's functions.""" 

109 self.modules: list[Module] = [] 

110 """The list of all the object's submodules.""" 

111 self.classes: list[Class] = [] 

112 """The list of all the object's classes.""" 

113 self.children: list[Object] = [] 

114 """The list of all the object's children.""" 

115 

116 def __str__(self) -> str: 

117 return self.path 

118 

119 @property 

120 def category(self) -> str: 

121 """Return the object's category. 

122 

123 Returns: 

124 The object's category (module, class, function, method or attribute). 

125 """ 

126 return self.__class__.__name__.lower() 

127 

128 @property 

129 def root(self) -> "Object": 

130 """Return the object's root. 

131 

132 Returns: 

133 The object's root (top-most parent). 

134 """ 

135 obj = self 

136 while obj.parent: 

137 obj = obj.parent 

138 return obj 

139 

140 @property 

141 def relative_file_path(self) -> str: 

142 """Return the relative file path of the object. 

143 

144 It is the relative path to the object's module, 

145 starting at the path of the top-most package it is contained in. 

146 

147 For example: 

148 

149 - package is `a` 

150 - package absolute path is `/abs/path/to/a` 

151 - module is `a.b.c` 

152 - object is `c` or anything defined in `c` 

153 - relative file path is `a/b/c.py` 

154 

155 If the relative file path cannot be determined, the value returned is `""` (empty string). 

156 

157 Returns: 

158 The path relative to the object's package. 

159 """ 

160 parts = self.path.split(".") 

161 namespaces = [".".join(parts[:length]) for length in range(1, len(parts) + 1)] 

162 # Iterate through all sub namespaces including the last in case it is a module 

163 for namespace in namespaces: 163 ↛ 184line 163 didn't jump to line 184 because the loop on line 163 didn't complete

164 try: 

165 importlib.import_module(namespace) 

166 top_package = sys.modules[namespace] 

167 except (ModuleNotFoundError, ImportError, KeyError): 

168 # ImportError: Triggered if the namespace is not importable 

169 # ModuleNotFoundError: Triggered if the namespace is not a module 

170 # KeyError: Triggered if the imported package isn't referenced under the same fully qualified name 

171 # Namespace packages are importable, so this should work for them 

172 return "" 

173 

174 try: 

175 top_package_path = Path(inspect.getabsfile(top_package)).parent 

176 return str(Path(self.file_path).relative_to(top_package_path.parent)) 

177 except TypeError: 

178 # Triggered if getabsfile() can't be found in the case of a Namespace package 

179 pass 

180 except ValueError: 

181 # Triggered if Path().relative_to can't find an appropriate path 

182 return "" 

183 

184 return "" 

185 

186 @property 

187 def name_to_check(self) -> str: 

188 """Return the attribute to check against name-properties regular expressions (private, class-private, special). 

189 

190 Returns: 

191 The attribute to check (its name). 

192 """ 

193 return self.name 

194 

195 @property 

196 def name_properties(self) -> list[str]: 

197 """Return the object's name properties. 

198 

199 Returns: 

200 The object's name properties (private, class-private, special). 

201 """ 

202 properties = [] 

203 for prop, predicate in self.possible_name_properties: 

204 if predicate(self.name_to_check): 

205 properties.append(prop) 

206 return properties 

207 

208 @property 

209 def parent_path(self) -> str: 

210 """Return the parent's path, computed from the current path. 

211 

212 The parent object path is not used: this property is used to see if an object is really related to another one, 

213 to add it as a child to the other. When we do that, the child doesn't even have a parent. 

214 

215 Returns: 

216 The dotted path of the parent object. 

217 """ 

218 return self.path.rsplit(".", 1)[0] 

219 

220 def add_child(self, obj: "Object") -> None: 

221 """Add an object as a child of this object. 

222 

223 If the child computed `parent_path` is not equal to this object's path, abort. 

224 

225 Append the child to the `children` list, and to the right category list. 

226 

227 Arguments: 

228 obj: An instance of documented object. 

229 """ 

230 if obj.parent_path != self.path: 

231 return 

232 

233 self.children.append(obj) 

234 if isinstance(obj, Module): 

235 self.modules.append(obj) 

236 elif isinstance(obj, Class): 

237 self.classes.append(obj) 

238 elif isinstance(obj, Function): 

239 self.functions.append(obj) 

240 elif isinstance(obj, Method): 

241 self.methods.append(obj) 

242 elif isinstance(obj, Attribute): 242 ↛ 252line 242 didn't jump to line 252 because the condition on line 242 was always true

243 # Dataclass attributes with default values will already be present in `self.attributes` as they are 

244 # resolved differently by the python interpreter. As they have a concrete value, they are already present 

245 # in the "original" class. They should be overridden with the new "dataclass" attribute coming in here 

246 # (having the "dataclass_field" property set) 

247 new_attribute_name = obj.name 

248 for attribute in self.attributes: 

249 if attribute.name == new_attribute_name: 

250 self.attributes.remove(attribute) 

251 self.attributes.append(obj) 

252 obj.parent = self 

253 

254 self._path_map[obj.path] = obj 

255 

256 def add_children(self, children: list["Object"]) -> None: 

257 """Add a list of objects as children of this object. 

258 

259 Arguments: 

260 children: The list of children to add. 

261 """ 

262 for child in children: 

263 self.add_child(child) 

264 

265 def parse_docstring(self, parser: Parser, **context: Any) -> None: 

266 """Parse the docstring of this object. 

267 

268 Arguments: 

269 parser: A parser to parse the docstrings. 

270 **context: Additional context to use when parsing. 

271 """ 

272 if self.docstring and not self._parsed: 

273 sections, errors = parser.parse(self.docstring, {"obj": self, **context}) 

274 self.docstring_sections = sections 

275 self.docstring_errors = errors 

276 self._parsed = True 

277 

278 def parse_all_docstrings(self, parser: Parser) -> None: 

279 """Recursively parse the docstring of this object and its children. 

280 

281 Arguments: 

282 parser: A parser to parse the docstrings. 

283 """ 

284 self.parse_docstring(parser) 

285 for child in self.children: 

286 child.parse_all_docstrings(parser) 

287 

288 @lru_cache # noqa: B019 

289 def has_contents(self) -> bool: 

290 """Tells if the object has "contents". 

291 

292 An object has contents when: 

293 

294 - it is the root of the object tree 

295 - it has a docstring 

296 - at least one of its children (whatever the depth) has contents 

297 

298 The value is cached, so this method should be called last, when the tree doesn't change anymore. 

299 

300 Returns: 

301 Whether this object has contents or not. 

302 """ 

303 has_docstring = bool(self.docstring) 

304 is_root = not self.parent 

305 children_have_contents = any(child.has_contents() for child in self.children) 

306 return has_docstring or is_root or children_have_contents 

307 

308 

309class Module(Object): 

310 """A class to store information about a module.""" 

311 

312 possible_name_properties: list[ApplicableNameProperty] = [NAME_SPECIAL, NAME_PRIVATE] # noqa: RUF012 

313 

314 @property 

315 def file_name(self) -> str: 

316 """Return the base name of the module file, without the extension. 

317 

318 Returns: 

319 The module file's base name. 

320 """ 

321 return os.path.splitext(os.path.basename(self.file_path))[0] 

322 

323 @property 

324 def name_to_check(self) -> str: # noqa: D102 

325 return self.file_name 

326 

327 

328class Class(Object): 

329 """A class to store information about a class.""" 

330 

331 possible_name_properties: list[ApplicableNameProperty] = [NAME_PRIVATE] # noqa: RUF012 

332 

333 def __init__(self, *args: Any, bases: Optional[list[str]] = None, **kwargs: Any): 

334 """Initialize the object. 

335 

336 Arguments: 

337 *args: Arguments passed to the parent class Initialize the object. 

338 bases: The base classes (dotted paths). 

339 **kwargs: Arguments passed to the parent class Initialize the object. 

340 """ 

341 super().__init__(*args, **kwargs) 

342 self.bases = bases or ["object"] 

343 

344 

345class Function(Object): 

346 """A class to store information about a function. 

347 

348 It accepts an additional `signature` argument at instantiation. 

349 """ 

350 

351 possible_name_properties: list[ApplicableNameProperty] = [NAME_PRIVATE] # noqa: RUF012 

352 

353 def __init__(self, *args: Any, signature: Optional[inspect.Signature] = None, **kwargs: Any): 

354 """Initialize the object. 

355 

356 Arguments: 

357 *args: Arguments passed to the parent class Initialize the object. 

358 signature: The function signature. 

359 **kwargs: Arguments passed to the parent class Initialize the object. 

360 """ 

361 super().__init__(*args, **kwargs) 

362 self.signature = signature 

363 

364 

365class Method(Object): 

366 """A class to store information about a method. 

367 

368 It accepts an additional `signature` argument at instantiation. 

369 """ 

370 

371 possible_name_properties: list[ApplicableNameProperty] = [NAME_SPECIAL, NAME_PRIVATE] # noqa: RUF012 

372 

373 def __init__(self, *args: Any, signature: Optional[inspect.Signature] = None, **kwargs: Any): 

374 """Initialize the object. 

375 

376 Arguments: 

377 *args: Arguments passed to the parent class Initialize the object. 

378 signature: The function signature. 

379 **kwargs: Arguments passed to the parent class Initialize the object. 

380 """ 

381 super().__init__(*args, **kwargs) 

382 self.signature = signature 

383 

384 

385class Attribute(Object): 

386 """A class to store information about an attribute. 

387 

388 It accepts an additional `attr_type` argument at instantiation. 

389 """ 

390 

391 possible_name_properties: list[ApplicableNameProperty] = [NAME_SPECIAL, NAME_CLASS_PRIVATE, NAME_PRIVATE] # noqa: RUF012 

392 

393 def __init__(self, *args: Any, attr_type: Optional[Any] = None, **kwargs: Any): 

394 """Initialize the object. 

395 

396 Arguments: 

397 *args: Arguments passed to the parent class Initialize the object. 

398 attr_type: The attribute type. 

399 **kwargs: Arguments passed to the parent class Initialize the object. 

400 """ 

401 super().__init__(*args, **kwargs) 

402 self.type = attr_type