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
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-09 17:28 +0100
1"""This module defines the documented objects classes.
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
10Note that properties are considered attributes, because they are used like such.
12It also defines a convenient [`Source`][pytkdocs.objects.Source] class to represent source code.
13"""
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
24from pytkdocs.parsers.docstrings.base import Parser, Section
25from pytkdocs.properties import NAME_CLASS_PRIVATE, NAME_PRIVATE, NAME_SPECIAL, ApplicableNameProperty
28class Source:
29 """Helper class to represent source code.
31 It is simply used to wrap the result of
32 [`inspect.getsourceslines`](https://docs.python.org/3/library/inspect.html#inspect.getsourcelines).
33 """
35 def __init__(self, lines: Union[str, list[str]], line_start: int) -> None:
36 """Initialize the object.
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."""
49class Object(metaclass=ABCMeta): # noqa: B024
50 """A base class to store information about a Python object.
52 Each instance additionally stores references to its children, grouped by category.
53 """
55 possible_name_properties: list[ApplicableNameProperty] = [] # noqa: RUF012
56 """
57 The properties that we can apply to the object based on its name.
59 The applicable properties vary from one subclass of `Object` to another.
60 """
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.
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."""
100 self._path_map = {self.path: self}
101 self._parsed = False
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."""
116 def __str__(self) -> str:
117 return self.path
119 @property
120 def category(self) -> str:
121 """Return the object's category.
123 Returns:
124 The object's category (module, class, function, method or attribute).
125 """
126 return self.__class__.__name__.lower()
128 @property
129 def root(self) -> "Object":
130 """Return the object's root.
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
140 @property
141 def relative_file_path(self) -> str:
142 """Return the relative file path of the object.
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.
147 For example:
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`
155 If the relative file path cannot be determined, the value returned is `""` (empty string).
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 ""
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 ""
184 return ""
186 @property
187 def name_to_check(self) -> str:
188 """Return the attribute to check against name-properties regular expressions (private, class-private, special).
190 Returns:
191 The attribute to check (its name).
192 """
193 return self.name
195 @property
196 def name_properties(self) -> list[str]:
197 """Return the object's name properties.
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
208 @property
209 def parent_path(self) -> str:
210 """Return the parent's path, computed from the current path.
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.
215 Returns:
216 The dotted path of the parent object.
217 """
218 return self.path.rsplit(".", 1)[0]
220 def add_child(self, obj: "Object") -> None:
221 """Add an object as a child of this object.
223 If the child computed `parent_path` is not equal to this object's path, abort.
225 Append the child to the `children` list, and to the right category list.
227 Arguments:
228 obj: An instance of documented object.
229 """
230 if obj.parent_path != self.path:
231 return
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
254 self._path_map[obj.path] = obj
256 def add_children(self, children: list["Object"]) -> None:
257 """Add a list of objects as children of this object.
259 Arguments:
260 children: The list of children to add.
261 """
262 for child in children:
263 self.add_child(child)
265 def parse_docstring(self, parser: Parser, **context: Any) -> None:
266 """Parse the docstring of this object.
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
278 def parse_all_docstrings(self, parser: Parser) -> None:
279 """Recursively parse the docstring of this object and its children.
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)
288 @lru_cache # noqa: B019
289 def has_contents(self) -> bool:
290 """Tells if the object has "contents".
292 An object has contents when:
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
298 The value is cached, so this method should be called last, when the tree doesn't change anymore.
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
309class Module(Object):
310 """A class to store information about a module."""
312 possible_name_properties: list[ApplicableNameProperty] = [NAME_SPECIAL, NAME_PRIVATE] # noqa: RUF012
314 @property
315 def file_name(self) -> str:
316 """Return the base name of the module file, without the extension.
318 Returns:
319 The module file's base name.
320 """
321 return os.path.splitext(os.path.basename(self.file_path))[0]
323 @property
324 def name_to_check(self) -> str: # noqa: D102
325 return self.file_name
328class Class(Object):
329 """A class to store information about a class."""
331 possible_name_properties: list[ApplicableNameProperty] = [NAME_PRIVATE] # noqa: RUF012
333 def __init__(self, *args: Any, bases: Optional[list[str]] = None, **kwargs: Any):
334 """Initialize the object.
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"]
345class Function(Object):
346 """A class to store information about a function.
348 It accepts an additional `signature` argument at instantiation.
349 """
351 possible_name_properties: list[ApplicableNameProperty] = [NAME_PRIVATE] # noqa: RUF012
353 def __init__(self, *args: Any, signature: Optional[inspect.Signature] = None, **kwargs: Any):
354 """Initialize the object.
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
365class Method(Object):
366 """A class to store information about a method.
368 It accepts an additional `signature` argument at instantiation.
369 """
371 possible_name_properties: list[ApplicableNameProperty] = [NAME_SPECIAL, NAME_PRIVATE] # noqa: RUF012
373 def __init__(self, *args: Any, signature: Optional[inspect.Signature] = None, **kwargs: Any):
374 """Initialize the object.
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
385class Attribute(Object):
386 """A class to store information about an attribute.
388 It accepts an additional `attr_type` argument at instantiation.
389 """
391 possible_name_properties: list[ApplicableNameProperty] = [NAME_SPECIAL, NAME_CLASS_PRIVATE, NAME_PRIVATE] # noqa: RUF012
393 def __init__(self, *args: Any, attr_type: Optional[Any] = None, **kwargs: Any):
394 """Initialize the object.
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