Coverage for pyEDAA/Reports/DocumentationCoverage/Python.py: 96%
365 statements
« prev ^ index » next coverage.py v7.17.3, created at 2026-11-14 02:26 +0000
« prev ^ index » next coverage.py v7.17.3, created at 2026-11-14 02:26 +0000
1# ==================================================================================================================== #
2# _____ ____ _ _ ____ _ #
3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
7# |_| |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Electronic Design Automation Abstraction (EDA²) #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32Reader of Python source code for the documentation coverage model.
34:class:`Analyzer` reads a package's directory - every ``*.py`` file below it - into a
35:class:`~pyEDAA.Reports.DocumentationCoverage.DocumentationReport`. Each file is read once (:class:`SourceFile`): in its
36declared encoding, into its lines, its syntax tree and its tokens. The tokens hold the comments, which document a field
37or variable for Sphinx' autodoc, or excuse a missing doc-string for docstr_coverage.
39.. rubric:: Items
41* A package - a directory - and a module - a file - are items; a package's doc-string is its ``__init__.py``'s. A
42 directory without ``__init__.py`` is a namespace package, which has nothing to document.
43* Classes, functions, methods, constructors (``__init__``), destructors (``__del__``) and properties are items, wherever
44 they are declared, also in a function's body.
45* A class' fields are the names its body assigns or annotates - class variables and enumeration members too -, and its
46 ``type`` statements, also inside an ``if`` or ``try`` statement. A name assigned several times is one item.
47* A module's variables are the same statements in the module's body, e.g. constants, type aliases and type variables.
48* Imports, augmented assignments, assignments to an attribute (``self._x = ...``), names assigned in a function's body,
49 and the fields of a class declared in a function's body are no items.
51.. rubric:: Documentation
53* A module, class or callable is documented by a non-empty doc-string. A comment of docstr_coverage directly above its
54 declaration excuses a missing doc-string: ``# docstr-coverage: inherited`` and
55 ``# docstr-coverage: excused `reason```.
56* A field or variable is documented, where Sphinx' autodoc finds its documentation: a ``#:`` comment behind the
57 statement - behind a closing bracket, not behind an opening one -, ``#:`` comment lines directly above it, or a string
58 literal directly after it. The documentation must not be empty.
60.. rubric:: Ignored and excluded items
62* Ignored: a property's setter and deleter, an ``@overload`` stub, a field or variable named with a leading and a
63 trailing underscore (``__all__``, ``_value_``), and an item of a protected or private name, if :class:`CountedItems`
64 doesn't select these names.
65* Excluded: an item, whose qualified name matches one of :attr:`Analyzer.ExcludePatterns`, and the items it contains;
66 the fields or variables, if :class:`CountedItems` doesn't select them; a module, which can't be read or parsed
67 (:exc:`~pyEDAA.Reports.DocumentationCoverage.SourceFileWarning`).
68"""
69from __future__ import annotations
71from ast import AnnAssign, Assign, AST, AsyncFunctionDef, Attribute, Call, ClassDef, \
72 Constant, Expr, FunctionDef, If, List, Module, Name, Starred, \
73 Subscript, Try, Tuple, expr, get_docstring, parse, stmt
74from datetime import timedelta
75from enum import Flag
76from io import StringIO
77from pathlib import Path
78from re import Pattern, compile as re_compile, error as RegularExpressionError
79from sys import version_info
80from tokenize import COMMENT, ENDMARKER, NAME, NEWLINE, NL, NUMBER, OP, STRING, \
81 TokenError, TokenInfo, generate_tokens, open as tokenize_open
82from typing import ClassVar, Iterable, Optional as Nullable
84from pyTooling.Common import getFullyQualifiedName
85from pyTooling.Decorators import export, readonly
86from pyTooling.MetaClasses import ExtendedType
87from pyTooling.Stopwatch import Stopwatch
88from pyTooling.Warning import WarningCollector
90from pyEDAA.Reports.DocumentationCoverage import CoverageState, DocCoverageError, DocumentationReport, Item, ItemKind, \
91 SourceFileWarning
93if version_info >= (3, 12): # pragma: no cover
94 from ast import TypeAlias
97# A class with a property named like a class - ``Path`` - can't name that class in the annotation of a field: the class
98# body's namespace, where annotations are evaluated, binds the name to the property.
99_Path = Path
102@export
103class SourceFile(metaclass=ExtendedType, slots=True):
104 """
105 A Python source file, read once: its lines, its syntax tree, and its tokens, which hold the comments.
106 """
108 _COMMENT: ClassVar[Pattern[str]] = re_compile(r"^\s*#: ?(.*)\r?\n?$") #: ``#:`` line.
109 _INHERITED: ClassVar[Pattern[str]] = re_compile(r"#\s*docstr-coverage\s*:\s*inherit(ed)?\s*") #: Inherited.
110 _EXCUSED: ClassVar[Pattern[str]] = re_compile(r"#\s*docstr-coverage\s*:\s*excuse(d)?\s* `.*`\s*") #: Excused.
112 _path: _Path #: Path to the source file.
113 _lines: list[str] #: The source's lines, without line endings.
114 _tree: Module #: The source's syntax tree.
115 _tokens: list[TokenInfo] #: The source's tokens.
116 _parsingDuration: Nullable[timedelta] #: Duration of reading and parsing the file, if measured.
117 _positions: dict[tuple[int, int], int] #: The index of the token starting at a line and column.
118 _lineStarts: dict[int, int] #: The index of the first token starting in a line.
119 _closings: dict[int, int] #: The index of the closing bracket of an opening bracket.
121 def __init__(
122 self,
123 path: Path,
124 lines: list[str],
125 tree: Module,
126 tokens: list[TokenInfo],
127 *,
128 parsingDuration: Nullable[timedelta] = None
129 ) -> None:
130 """
131 Initializes a read source file, and indexes its tokens.
133 :param path: Path to the source file.
134 :param lines: The source's lines, without line endings.
135 :param tree: The source's syntax tree.
136 :param tokens: The source's tokens.
137 :param parsingDuration: Optional, the duration of reading and parsing the file. Default: ``None``.
138 :raises ValueError: If parameter ``path`` is ``None``.
139 :raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path`.
140 :raises ValueError: If parameter ``lines`` is ``None``.
141 :raises TypeError: If parameter ``lines`` isn't of type :class:`list`.
142 :raises ValueError: If parameter ``tree`` is ``None``.
143 :raises TypeError: If parameter ``tree`` isn't of type :class:`ast.Module`.
144 :raises ValueError: If parameter ``tokens`` is ``None``.
145 :raises TypeError: If parameter ``tokens`` isn't of type :class:`list`.
146 :raises TypeError: If parameter ``parsingDuration`` isn't of type :class:`~datetime.timedelta`.
147 """
148 for parameterName, value, expectedType in (
149 ("path", path, Path),
150 ("lines", lines, list),
151 ("tree", tree, Module),
152 ("tokens", tokens, list)
153 ):
154 if value is None:
155 raise ValueError(f"Parameter '{parameterName}' is None.")
156 elif not isinstance(value, expectedType):
157 ex = TypeError(f"Parameter '{parameterName}' is not of type '{expectedType.__name__}'.")
158 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
159 raise ex
161 if parsingDuration is not None and not isinstance(parsingDuration, timedelta):
162 ex = TypeError("Parameter 'parsingDuration' is not of type 'timedelta'.")
163 ex.add_note(f"Got type '{getFullyQualifiedName(parsingDuration)}'.")
164 raise ex
166 self._path = path
167 self._lines = lines
168 self._tree = tree
169 self._tokens = tokens
170 self._parsingDuration = parsingDuration
171 self._positions = {}
172 self._lineStarts = {}
173 self._closings = {}
175 openings: list[int] = []
176 for index, token in enumerate(tokens):
177 self._positions[token.start] = index
178 self._lineStarts.setdefault(token.start[0], index)
179 if token.type == OP:
180 if token.string in ("(", "[", "{"):
181 openings.append(index)
182 elif token.string in (")", "]", "}") and len(openings) > 0:
183 self._closings[openings.pop()] = index
185 @classmethod
186 def Read(cls, path: Path) -> SourceFile:
187 """
188 Read a source file in its declared encoding, and parse it, measuring the duration of both.
190 :param path: The source file.
191 :returns: The read source file.
192 :raises ValueError: If parameter ``path`` is ``None``.
193 :raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path`.
194 :raises OSError: If the file can't be read.
195 :raises SyntaxError: If the file declares an unknown encoding, or isn't valid Python.
196 :raises UnicodeDecodeError: If the file isn't encoded as it declares.
197 :raises ValueError: If the source contains a null byte (Python 3.11).
198 :raises TokenError: If the source ends inside a statement.
199 """
200 if path is None:
201 raise ValueError("Parameter 'path' is None.")
202 elif not isinstance(path, Path):
203 ex = TypeError("Parameter 'path' is not of type 'Path'.")
204 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
205 raise ex
207 with Stopwatch() as stopwatch:
208 with tokenize_open(path) as file:
209 source = file.read()
211 tree = parse(source, filename=str(path))
212 tokens = list(generate_tokens(StringIO(source).readline))
214 parsingDuration = stopwatch.Duration
215 return cls(path, source.split("\n"), tree, tokens, parsingDuration=parsingDuration)
217 @readonly
218 def Path(self) -> Path:
219 """
220 Read-only property to access the path to the source file (:attr:`_path`).
222 :returns: The path.
223 """
224 return self._path
226 @readonly
227 def Lines(self) -> list[str]:
228 """
229 Read-only property to access the source's lines (:attr:`_lines`).
231 :returns: The lines, without line endings.
232 """
233 return self._lines
235 @readonly
236 def Tree(self) -> Module:
237 """
238 Read-only property to access the source's syntax tree (:attr:`_tree`).
240 :returns: The syntax tree.
241 """
242 return self._tree
244 @readonly
245 def ParsingDuration(self) -> Nullable[timedelta]:
246 """
247 Read-only property to access the duration of reading, tokenizing and parsing the file (:attr:`_parsingDuration`).
249 :returns: The duration, or ``None`` if it wasn't measured.
250 """
251 return self._parsingDuration
253 def DocumentationState(self, node: Module | ClassDef | FunctionDef | AsyncFunctionDef) -> CoverageState:
254 """
255 Return how a module, class or function is documented: by a non-empty doc-string, or excused by a comment.
257 docstr_coverage's comment, which excuses a class or function, is the first comment above its declaration,
258 skipping blank lines and the lines of decorators.
260 :param node: The module, class or function.
261 :returns: The documentation state of the declaration.
262 """
263 docString = get_docstring(node)
264 if docString is not None and docString.strip() != "":
265 return CoverageState.Documented | CoverageState.Covered
266 elif isinstance(node, Module):
267 return CoverageState.Undocumented
269 index = self._lineStarts.get(node.lineno, 0) - 1
270 while index >= 0: 270 ↛ 284line 270 didn't jump to line 284 because the condition on line 270 was always true
271 token = self._tokens[index]
272 if token.type in (NL, NEWLINE) or (token.type == NAME and token.string == "class") or \
273 token.line.strip().startswith("@"):
274 index -= 1
275 elif token.type != COMMENT:
276 break
277 elif self._INHERITED.match(token.string) is not None:
278 return CoverageState.Inherited | CoverageState.Covered
279 elif self._EXCUSED.match(token.string) is not None:
280 return CoverageState.Excused | CoverageState.Covered
281 else:
282 break
284 return CoverageState.Undocumented
286 def HasDocumentationComment(self, statement: stmt) -> bool:
287 """
288 Return whether an assignment is documented by a non-empty ``#:`` comment, where Sphinx' autodoc finds it.
290 Like autodoc, the comment behind the statement is looked up first - a comment behind an opening bracket belongs to
291 the value -, then the ``#:`` comment lines directly above the statement.
293 :param statement: The assignment.
294 :returns: ``True``, if a comment documents the assignment.
295 """
296 line = self._lines[statement.lineno - 1]
297 column = len(line.encode("utf-8")[:statement.col_offset].decode("utf-8", errors="replace"))
299 if (index := self._positions.get((statement.lineno, column), None)) is not None: 299 ↛ 315line 299 didn't jump to line 315 because the condition on line 299 was always true
300 token = self._tokens[index]
301 while not (token.type == OP and token.string == "=") and token.type not in (NEWLINE, COMMENT, ENDMARKER):
302 index += 1
303 token = self._tokens[index]
305 if token.type == OP and token.string == "=":
306 index += 1
307 token = self._tokens[index]
308 while token.type in (OP, NAME, NUMBER, STRING) and not (token.type == OP and token.string == ";"):
309 index = self._closings.get(index, index) + 1
310 token = self._tokens[index]
312 if token.type == COMMENT and (match := self._COMMENT.match(token.string)) is not None:
313 return match[1].strip() != ""
315 if line[:column].strip() != "": 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true
316 return False
318 for lineIndex in range(statement.lineno - 2, -1, -1): 318 ↛ 324line 318 didn't jump to line 324 because the loop on line 318 didn't complete
319 if (match := self._COMMENT.match(self._lines[lineIndex])) is None:
320 return False
321 elif match[1].strip() != "": 321 ↛ 318line 321 didn't jump to line 318 because the condition on line 321 was always true
322 return True
324 return False
327@export
328class CountedItems(Flag):
329 """
330 The items an analysis counts, besides the packages, modules, classes and callables of a public name.
332 A protected name has one leading underscore; a private name two, but not two trailing ones like a dunder name. An
333 item of a name, which isn't selected, is ignored. Fields and variables, which aren't selected, are excluded.
334 """
336 Fields = 1 #: The fields of a class.
337 Variables = 2 #: The variables of a module.
338 Protected = 4 #: Items of a protected name, e.g. ``_x``.
339 Private = 8 #: Items of a private, name-mangled name, e.g. ``__x``.
341 Default = Fields | Variables | Protected | Private #: All items.
344# A class with a property named like a class - ``CountedItems`` - can't name that class in the annotation of a field:
345# the class body's namespace, where annotations are evaluated, binds the name to the property.
346_CountedItems = CountedItems
349@export
350class Analyzer(metaclass=ExtendedType, slots=True):
351 """
352 Reads the documentation coverage of a Python package from its source files.
354 A package's or module's lines are its file's, so their items name no lines.
356 .. rubric:: Example
358 .. code-block:: Python
360 from pathlib import Path
361 from pyEDAA.Reports.DocumentationCoverage.Python import Analyzer, CountedItems
362 from pyTooling.Warning import WarningCollector
364 with WarningCollector() as warnings:
365 counted = CountedItems.Fields | CountedItems.Variables
366 report = Analyzer("myPackage", Path("src/myPackage"), countedItems=counted).Analyze()
368 print(f"{report.Counters.Covered}/{report.Counters.Expected}: {report.Counters.Coverage:.1%}")
369 """
371 _packageName: str #: Name of the package.
372 _directory: Path #: The package's directory.
373 _countedItems: _CountedItems #: The items the analysis counts.
374 _excludePatterns: list[Pattern[str]] #: The patterns of qualified names, whose items are excluded.
376 def __init__(
377 self,
378 packageName: str,
379 directory: Path,
380 *,
381 countedItems: CountedItems = CountedItems.Default,
382 excludePatterns: Nullable[Iterable[str]] = None
383 ) -> None:
384 """
385 Initializes the analysis of a package.
387 :param packageName: Name of the package, which is the name of the top-level item, e.g. ``myPackage``.
388 :param directory: The package's directory.
389 :param countedItems: Optional, the items the analysis counts. Default: all of them.
390 :param excludePatterns: Optional, regular expressions matching the whole qualified name of an item to exclude,
391 e.g. ``myPackage\\.CLI``; the items it contains are excluded too. Default: none.
392 :raises ValueError: If parameter ``packageName`` is ``None``.
393 :raises TypeError: If parameter ``packageName`` isn't of type :class:`str`.
394 :raises ValueError: If parameter ``packageName`` is empty.
395 :raises ValueError: If parameter ``directory`` is ``None``.
396 :raises TypeError: If parameter ``directory`` isn't of type :class:`~pathlib.Path`.
397 :raises ValueError: If parameter ``countedItems`` is ``None``.
398 :raises TypeError: If parameter ``countedItems`` isn't of type :class:`CountedItems`.
399 :raises TypeError: If parameter ``excludePatterns`` isn't iterable.
400 :raises TypeError: If parameter ``excludePatterns`` contains an element not of type :class:`str`.
401 :raises ValueError: If parameter ``excludePatterns`` contains an invalid regular expression.
402 """
403 if packageName is None: 403 ↛ 404line 403 didn't jump to line 404 because the condition on line 403 was never true
404 raise ValueError("Parameter 'packageName' is None.")
405 elif not isinstance(packageName, str): 405 ↛ 406line 405 didn't jump to line 406 because the condition on line 405 was never true
406 ex = TypeError("Parameter 'packageName' is not of type 'str'.")
407 ex.add_note(f"Got type '{getFullyQualifiedName(packageName)}'.")
408 raise ex
409 elif packageName == "":
410 raise ValueError("Parameter 'packageName' is empty.")
412 if directory is None: 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true
413 raise ValueError("Parameter 'directory' is None.")
414 elif not isinstance(directory, Path):
415 ex = TypeError("Parameter 'directory' is not of type 'Path'.")
416 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.")
417 raise ex
419 if countedItems is None:
420 raise ValueError("Parameter 'countedItems' is None.")
421 elif not isinstance(countedItems, CountedItems):
422 ex = TypeError("Parameter 'countedItems' is not of type 'CountedItems'.")
423 ex.add_note(f"Got type '{getFullyQualifiedName(countedItems)}'.")
424 raise ex
426 self._packageName = packageName
427 self._directory = directory
428 self._countedItems = countedItems
429 self._excludePatterns = excludePatternList = []
431 if excludePatterns is not None:
432 if not isinstance(excludePatterns, Iterable):
433 ex = TypeError("Parameter 'excludePatterns' is not iterable.")
434 ex.add_note(f"Got type '{getFullyQualifiedName(excludePatterns)}'.")
435 raise ex
437 for excludePattern in excludePatterns:
438 if not isinstance(excludePattern, str):
439 ex = TypeError("Parameter 'excludePatterns' contains an element not of type 'str'.")
440 ex.add_note(f"Got type '{getFullyQualifiedName(excludePattern)}'.")
441 raise ex
443 try:
444 excludePatternList.append(re_compile(excludePattern))
445 except RegularExpressionError as cause:
446 ex = ValueError("Parameter 'excludePatterns' contains an invalid regular expression.")
447 ex.add_note(f"Got pattern '{excludePattern}'.")
448 raise ex from cause
450 @readonly
451 def PackageName(self) -> str:
452 """
453 Read-only property to access the name of the package (:attr:`_packageName`).
455 :returns: The package's name.
456 """
457 return self._packageName
459 @readonly
460 def Directory(self) -> Path:
461 """
462 Read-only property to access the package's directory (:attr:`_directory`).
464 :returns: The directory.
465 """
466 return self._directory
468 @readonly
469 def CountedItems(self) -> CountedItems:
470 """
471 Read-only property to access the items the analysis counts (:attr:`_countedItems`).
473 :returns: The counted items.
474 """
475 return self._countedItems
477 @readonly
478 def ExcludePatterns(self) -> list[Pattern[str]]:
479 """
480 Read-only property to access the patterns of qualified names, whose items are excluded (:attr:`_excludePatterns`).
482 :returns: The compiled regular expressions.
483 """
484 return self._excludePatterns
486 def Analyze(self) -> DocumentationReport:
487 """
488 Read every Python source file of the package into a documentation coverage report, and aggregate it.
490 The report's :attr:`~pyEDAA.Reports.DocumentationCoverage.DocumentationReport.AnalysisDuration` is the
491 duration of the analysis. A source file, which can't be read or parsed, is raised as a
492 :exc:`~pyEDAA.Reports.DocumentationCoverage.SourceFileWarning` to the current
493 :class:`~pyTooling.Warning.WarningCollector`; its module is excluded, and the analysis continues.
495 :returns: The report; its top-level item is the package.
496 :raises DocCoverageError: If the package's directory doesn't exist.
497 :raises UnhandledCriticalWarningError: If a source file can't be analyzed, and no warning
498 collector collects the warning. |br|
499 Add a with-statement using
500 :class:`~pyTooling.Warning.WarningCollector`.
501 """
502 if not self._directory.is_dir():
503 raise DocCoverageError(f"Package directory '{self._directory.as_posix()}' doesn't exist.")
505 with Stopwatch() as stopwatch:
506 report = DocumentationReport(Path(self._packageName))
507 self._ReadPackage(self._directory, self._packageName, report, report)
508 report.Aggregate()
510 report._analysisDuration = stopwatch.Duration
512 return report
514 def _ReadPackage(
515 self,
516 directory: Path,
517 name: str,
518 parent: Item | DocumentationReport,
519 report: DocumentationReport
520 ) -> None:
521 """
522 Read a package's ``__init__.py``, its modules, then its sub-packages, each sorted by name.
524 :param directory: The package's directory.
525 :param name: The package's name.
526 :param parent: The package containing this one, or the report.
527 :param report: The report.
528 """
529 if (directory / "__init__.py").is_file():
530 package = self._ReadModule(directory / "__init__.py", name, ItemKind.Package, parent, report)
531 else:
532 package = Item(name, ItemKind.Package, self._State(CoverageState.Empty, name, parent), parent=parent)
534 paths = sorted(directory.iterdir(), key=Path.as_posix)
535 for path in paths:
536 if path.suffix == ".py" and path.name != "__init__.py" and path.is_file():
537 self._ReadModule(path, path.stem, ItemKind.Module, package, report)
539 for path in paths:
540 if path.is_dir() and next(path.rglob("*.py"), None) is not None:
541 self._ReadPackage(path, path.name, package, report)
543 def _ReadModule(
544 self,
545 path: Path,
546 name: str,
547 kind: ItemKind,
548 parent: Item | DocumentationReport,
549 report: DocumentationReport
550 ) -> Item:
551 """
552 Read a module, or a package's ``__init__.py``, and the items declared in it.
554 :param path: The source file.
555 :param name: The module's or package's name.
556 :param kind: The kind of the module's item.
557 :param parent: The package containing the module, or the report.
558 :param report: The report.
559 :returns: The module's or package's item.
560 :raises UnhandledCriticalWarningError: If the source file can't be analyzed, and no warning
561 collector collects the warning.
562 """
563 file = report.GetOrAddFile(path.relative_to(self._directory))
564 try:
565 sourceFile = SourceFile.Read(path)
566 except (OSError, SyntaxError, TokenError, ValueError) as ex:
567 WarningCollector.Raise(
568 SourceFileWarning(f"Source file '{file.Path.as_posix()}' can't be analyzed.", file=file),
569 ex
570 )
571 return Item(name, kind, CoverageState.Excluded, file=file, parent=parent)
573 if len(sourceFile.Tree.body) == 0:
574 documentation = CoverageState.Empty
575 else:
576 documentation = sourceFile.DocumentationState(sourceFile.Tree)
578 module = Item(name, kind, self._State(documentation, name, parent), file=file, parent=parent)
579 self._ReadBody(sourceFile, sourceFile.Tree.body, module, False)
581 return module
583 def _ReadBody(self, sourceFile: SourceFile, body: list[stmt], parent: Item, inFunction: bool) -> None:
584 """
585 Read the items declared in a module's, class' or function's body, in the order of their first declaration.
587 Classes and functions are read wherever they are declared. Fields and variables are read from a module's or class'
588 body and the ``if`` and ``try`` statements in it, but not from a function's body or a class declared in it. An
589 assignment to a field in the class' ``__init__`` documents the field, if commented.
591 :param sourceFile: The source file.
592 :param body: The statements of the body.
593 :param parent: The module's, class' or function's item.
594 :param inFunction: Whether the body is a function's, or is declared in a function's body.
595 """
596 order: list[stmt | str] = []
597 first: dict[str, stmt] = {}
598 documented: dict[str, bool] = {}
599 previous: Nullable[AST] = None
601 def visit(statements: list[stmt], readAttributes: bool, selfName: str) -> None:
602 """
603 Nested function for recursion.
605 The statement before an expression is tracked as Sphinx' autodoc does: a string after an assignment documents
606 it; the condition of an ``if`` statement is the statement before its first block.
608 :param statements: The statements of a block.
609 :param readAttributes: Whether the block's fields or variables are read.
610 :param selfName: Empty for a module's or class' body. In the body of a class' ``__init__``: the name of
611 its first parameter, whose attributes document the class' fields.
612 """
613 nonlocal previous
614 for statement in statements:
615 if isinstance(statement, (ClassDef, FunctionDef, AsyncFunctionDef)):
616 if selfName == "": 616 ↛ 651line 616 didn't jump to line 651 because the condition on line 616 was always true
617 order.append(statement)
618 elif readAttributes and len(names := self._TargetNames(statement, False, selfName)) > 0:
619 hasComment = sourceFile.HasDocumentationComment(statement)
620 for name in names:
621 if selfName == "" and name not in first:
622 order.append(name)
623 first[name] = statement
624 documented[name] = False
626 if name in documented:
627 documented[name] = documented[name] or hasComment
628 elif isinstance(statement, Expr) and isinstance(statement.value, Constant) and \
629 isinstance(statement.value.value, str) and statement.value.value.strip() != "":
630 for name in self._TargetNames(previous, True, selfName):
631 if name in documented: 631 ↛ 630line 631 didn't jump to line 630 because the condition on line 631 was always true
632 documented[name] = True
633 elif isinstance(statement, If):
634 previous = statement.test
635 visit(statement.body, readAttributes, selfName)
636 visit(statement.orelse, readAttributes, selfName)
637 elif isinstance(statement, Try):
638 visit(statement.body, readAttributes, selfName)
639 visit(statement.orelse, readAttributes, selfName)
640 for handler in statement.handlers:
641 visit(handler.body, False, selfName)
643 visit(statement.finalbody, False, selfName)
644 else:
645 for block in ("body", "orelse", "finalbody"):
646 visit(getattr(statement, block, []), False, selfName)
648 for clause in (*getattr(statement, "handlers", []), *getattr(statement, "cases", [])): 648 ↛ 649line 648 didn't jump to line 649 because the loop on line 648 never started
649 visit(clause.body, False, selfName)
651 previous = statement
653 isContainer = parent._kind in (ItemKind.Package, ItemKind.Module, ItemKind.Class, ItemKind.Enumeration)
654 visit(body, isContainer and not inFunction, "")
656 if parent._kind in (ItemKind.Class, ItemKind.Enumeration) and not inFunction:
657 for entry in [entry for entry in order if isinstance(entry, (FunctionDef, AsyncFunctionDef))]:
658 if entry.name == "__init__" and len(parameters := (*entry.args.posonlyargs, *entry.args.args)) > 0:
659 previous = entry
660 visit(entry.body, True, parameters[0].arg)
662 for entry in order:
663 if isinstance(entry, ClassDef):
664 self._ReadClass(sourceFile, entry, parent, inFunction)
665 elif isinstance(entry, (FunctionDef, AsyncFunctionDef)):
666 self._ReadFunction(sourceFile, entry, parent)
667 else:
668 self._ReadAttribute(entry, first[entry], documented[entry], parent)
670 def _ReadClass(self, sourceFile: SourceFile, statement: ClassDef, parent: Item, inFunction: bool) -> None:
671 """
672 Read a class, and the items declared in its body.
674 A class deriving from a class named ``...Enum`` or ``...Flag`` is an enumeration.
676 :param sourceFile: The source file.
677 :param statement: The class' declaration.
678 :param parent: The item containing the class.
679 :param inFunction: Whether the class is declared in a function's body.
680 """
681 if any(self._BaseName(base).endswith(("Enum", "Flag")) for base in statement.bases):
682 kind = ItemKind.Enumeration
683 else:
684 kind = ItemKind.Class
686 item = Item(
687 statement.name,
688 kind,
689 self._State(sourceFile.DocumentationState(statement), statement.name, parent),
690 file=parent._file,
691 startLine=self._FirstLine(statement),
692 endLine=statement.end_lineno,
693 parent=parent
694 )
695 self._ReadBody(sourceFile, statement.body, item, inFunction)
697 def _ReadFunction(self, sourceFile: SourceFile, statement: FunctionDef | AsyncFunctionDef, parent: Item) -> None:
698 """
699 Read a function, method, constructor or property, and the classes and functions declared in its body.
701 A property's setter and deleter and an ``@overload`` stub are ignored.
703 :param sourceFile: The source file.
704 :param statement: The function's declaration.
705 :param parent: The item containing the function.
706 """
707 decorators = {self._BaseName(decorator) for decorator in statement.decorator_list}
708 isAccessor = any(
709 isinstance(decorator, Attribute) and decorator.attr in ("setter", "deleter")
710 for decorator in statement.decorator_list
711 )
712 isMember = parent._kind in (ItemKind.Class, ItemKind.Enumeration)
714 if isAccessor or (isMember and not decorators.isdisjoint(("property", "readonly", "cached_property", "getter"))):
715 kind = ItemKind.Property
716 elif isMember and statement.name == "__init__":
717 kind = ItemKind.Constructor
718 elif isMember and statement.name == "__del__":
719 kind = ItemKind.Destructor
720 elif isMember:
721 kind = ItemKind.Method
722 else:
723 kind = ItemKind.Function
725 item = Item(
726 statement.name,
727 kind,
728 self._State(
729 sourceFile.DocumentationState(statement),
730 statement.name,
731 parent,
732 ignored=isAccessor or "overload" in decorators
733 ),
734 file=parent._file,
735 startLine=self._FirstLine(statement),
736 endLine=statement.end_lineno,
737 parent=parent
738 )
739 self._ReadBody(sourceFile, statement.body, item, True)
741 def _ReadAttribute(self, name: str, statement: stmt, documented: bool, parent: Item) -> None:
742 """
743 Read a field of a class or a variable of a module, declared first by a statement.
745 A variable assigned a call of ``TypeVar``, ``ParamSpec`` or ``TypeVarTuple`` is a type variable, of ``NewType`` a
746 type; a variable annotated ``Final`` or named in upper case is a constant.
748 :param name: The field's or variable's name.
749 :param statement: The first statement assigning or annotating the name.
750 :param documented: Whether a statement assigning or annotating the name documents it.
751 :param parent: The class' or module's item.
752 """
753 isField = parent._kind in (ItemKind.Class, ItemKind.Enumeration)
754 annotationName = self._BaseName(statement.annotation) if isinstance(statement, AnnAssign) else ""
755 callName = self._BaseName(statement.value.func) if isinstance(getattr(statement, "value", None), Call) else ""
757 if (version_info >= (3, 12) and isinstance(statement, TypeAlias)) or annotationName == "TypeAlias":
758 kind = ItemKind.TypeAlias
759 elif isField and parent._kind is ItemKind.Enumeration and isinstance(statement, Assign):
760 kind = ItemKind.EnumerationMember
761 elif isField:
762 kind = ItemKind.Field
763 elif callName in ("TypeVar", "ParamSpec", "TypeVarTuple"):
764 kind = ItemKind.TypeVariable
765 elif callName == "NewType":
766 kind = ItemKind.Type
767 elif annotationName == "Final" or name.isupper():
768 kind = ItemKind.Constant
769 else:
770 kind = ItemKind.Variable
772 Item(
773 name,
774 kind,
775 self._State(
776 CoverageState.Documented | CoverageState.Covered if documented else CoverageState.Undocumented,
777 name,
778 parent,
779 ignored=name.startswith("_") and name.endswith("_"),
780 excluded=(CountedItems.Fields if isField else CountedItems.Variables) not in self._countedItems
781 ),
782 file=parent._file,
783 startLine=statement.lineno,
784 endLine=statement.end_lineno,
785 parent=parent
786 )
788 def _State(
789 self,
790 documentation: CoverageState,
791 name: str,
792 parent: Item | DocumentationReport,
793 *,
794 ignored: bool = False,
795 excluded: bool = False
796 ) -> CoverageState:
797 """
798 Return an item's coverage state: how it is documented, and whether it is excluded or ignored.
800 An item is excluded, if its qualified name, or the qualified name of an item containing it, matches an exclude
801 pattern. It is ignored, if its name is protected or private, and :class:`CountedItems` doesn't select such names.
803 :param documentation: How the item is documented.
804 :param name: The item's name.
805 :param parent: The item's parent.
806 :param ignored: Optional, whether the reader's rules ignore the item. Default: ``False``.
807 :param excluded: Optional, whether the options exclude the item. Default: ``False``.
808 :returns: The coverage state.
809 """
810 names = f"{parent.QualifiedName}.{name}".split(".") if isinstance(parent, Item) else name.split(".")
811 if excluded or any(
812 pattern.fullmatch(".".join(names[:length])) is not None
813 for pattern in self._excludePatterns for length in range(1, len(names) + 1)
814 ):
815 return documentation | CoverageState.Excluded
816 elif ignored or (
817 name.startswith("_") and not name.startswith("__") and CountedItems.Protected not in self._countedItems
818 ) or (
819 name.startswith("__") and not name.endswith("__") and CountedItems.Private not in self._countedItems
820 ):
821 return documentation | CoverageState.Ignored
823 return documentation
825 @staticmethod
826 def _TargetNames(statement: Nullable[AST], firstTargetOnly: bool, selfName: str) -> list[str]:
827 """
828 Return the names an assignment, an annotation or a ``type`` statement declares, as Sphinx' autodoc reads them.
830 In a module's or class' body, a name target declares a name. In the body of a class' ``__init__``, an attribute
831 of its first parameter - ``self._x`` - declares the attribute's name.
833 :param statement: The statement; any other statement or ``None`` declares no name.
834 :param firstTargetOnly: Whether only the first target of a chained assignment counts.
835 :param selfName: Empty for a module's or class' body; the name of ``__init__``'s first parameter in its body.
836 :returns: The names; none, if a target declares no name, e.g. a subscript.
837 """
838 if isinstance(statement, Assign):
839 targets = statement.targets[:1] if firstTargetOnly else statement.targets
840 elif isinstance(statement, AnnAssign):
841 targets = [statement.target]
842 elif version_info >= (3, 12) and isinstance(statement, TypeAlias):
843 targets = [statement.name]
844 else:
845 return []
847 def targetName(target: expr) -> str:
848 """
849 Nested function returning the name a target - neither a tuple nor a list - declares.
851 :param target: The target.
852 :returns: The name; empty, if the target declares none.
853 """
854 if isinstance(target, Name) and (selfName == "" or target.id == selfName):
855 return target.id
856 elif isinstance(target, Attribute) and selfName != "" and isinstance(target.value, Name) and \
857 target.value.id == selfName:
858 return target.attr
860 return ""
862 names: list[str] = []
863 for target in targets:
864 if isinstance(target, (Tuple, List)):
865 for element in target.elts:
866 if (name := targetName(element.value if isinstance(element, Starred) else element)) != "": 866 ↛ 865line 866 didn't jump to line 865 because the condition on line 866 was always true
867 names.append(name)
868 elif (name := targetName(target)) != "":
869 names.append(name)
870 else:
871 return []
873 return names
875 @staticmethod
876 def _BaseName(expression: expr) -> str:
877 """
878 Return the name an expression is based on: of a name, an attribute, a subscripted or a called expression.
880 ``typing.ClassVar[int]`` is based on ``ClassVar``, ``functools.cached_property`` on ``cached_property``.
882 :param expression: The expression.
883 :returns: The name; empty, if the expression isn't based on a name.
884 """
885 if isinstance(expression, Name):
886 return expression.id
887 elif isinstance(expression, Attribute):
888 return expression.attr
889 elif isinstance(expression, Subscript):
890 return Analyzer._BaseName(expression.value)
891 elif isinstance(expression, Call): 891 ↛ 894line 891 didn't jump to line 894 because the condition on line 891 was always true
892 return Analyzer._BaseName(expression.func)
894 return ""
896 @staticmethod
897 def _FirstLine(statement: ClassDef | FunctionDef | AsyncFunctionDef) -> int:
898 """
899 Return a declaration's first line: its first decorator's, if it has decorators.
901 :param statement: The declaration.
902 :returns: The line number.
903 """
904 return statement.decorator_list[0].lineno if len(statement.decorator_list) > 0 else statement.lineno