Coverage for pyEDAA/Reports/DocumentationCoverage/Python.py: 96%
370 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +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
72from ast import Constant, Expr, FunctionDef, If, List, Module, Name, Starred, Subscript
73from ast import 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
79from re import error as RegularExpressionError
80from sys import version_info
81from tokenize import COMMENT, ENDMARKER, NAME, NEWLINE, NL, NUMBER, OP, STRING
82from tokenize import TokenError, TokenInfo, generate_tokens, open as tokenize_open
83from typing import ClassVar, Iterable, Optional as Nullable
85from pyTooling.Common import getFullyQualifiedName
86from pyTooling.Decorators import export, readonly
87from pyTooling.MetaClasses import ExtendedType
88from pyTooling.Stopwatch import Stopwatch
89from pyTooling.Warning import WarningCollector
91from pyEDAA.Reports.DocumentationCoverage import CoverageState, DocCoverageError, DocumentationReport, Item, ItemKind
92from pyEDAA.Reports.DocumentationCoverage import SourceFileWarning
94if version_info >= (3, 12): # pragma: no cover
95 from ast import TypeAlias
98# A class with a property named like a class - ``Path`` - can't name that class in the annotation of a field: the class
99# body's namespace, where annotations are evaluated, binds the name to the property.
100_Path = Path
103@export
104class SourceFile(metaclass=ExtendedType, slots=True):
105 """
106 A Python source file, read once: its lines, its syntax tree, and its tokens, which hold the comments.
107 """
109 _COMMENT: ClassVar[Pattern[str]] = re_compile(r"^\s*#: ?(.*)\r?\n?$") #: ``#:`` line.
110 _INHERITED: ClassVar[Pattern[str]] = re_compile(r"#\s*docstr-coverage\s*:\s*inherit(ed)?\s*") #: Inherited.
111 _EXCUSED: ClassVar[Pattern[str]] = re_compile(r"#\s*docstr-coverage\s*:\s*excuse(d)?\s* `.*`\s*") #: Excused.
113 _path: _Path #: Path to the source file.
114 _lines: list[str] #: The source's lines, without line endings.
115 _tree: Module #: The source's syntax tree.
116 _tokens: list[TokenInfo] #: The source's tokens.
117 _parsingDuration: Nullable[timedelta] #: Duration of reading and parsing the file, if measured.
118 _positions: dict[tuple[int, int], int] #: The index of the token starting at a line and column.
119 _lineStarts: dict[int, int] #: The index of the first token starting in a line.
120 _closings: dict[int, int] #: The index of the closing bracket of an opening bracket.
122 def __init__(
123 self,
124 path: Path,
125 lines: list[str],
126 tree: Module,
127 tokens: list[TokenInfo],
128 *,
129 parsingDuration: Nullable[timedelta] = None
130 ) -> None:
131 """
132 Initializes a read source file, and indexes its tokens.
134 :param path: Path to the source file.
135 :param lines: The source's lines, without line endings.
136 :param tree: The source's syntax tree.
137 :param tokens: The source's tokens.
138 :param parsingDuration: Optional, the duration of reading and parsing the file. Default: ``None``.
139 :raises ValueError: If parameter ``path`` is ``None``.
140 :raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path`.
141 :raises ValueError: If parameter ``lines`` is ``None``.
142 :raises TypeError: If parameter ``lines`` isn't of type :class:`list`.
143 :raises ValueError: If parameter ``tree`` is ``None``.
144 :raises TypeError: If parameter ``tree`` isn't of type :class:`ast.Module`.
145 :raises ValueError: If parameter ``tokens`` is ``None``.
146 :raises TypeError: If parameter ``tokens`` isn't of type :class:`list`.
147 :raises TypeError: If parameter ``parsingDuration`` isn't of type :class:`~datetime.timedelta`.
148 """
149 for parameterName, value, expectedType in (
150 ("path", path, Path),
151 ("lines", lines, list),
152 ("tree", tree, Module),
153 ("tokens", tokens, list)
154 ):
155 if value is None:
156 raise ValueError(f"Parameter '{parameterName}' is None.")
157 elif not isinstance(value, expectedType):
158 ex = TypeError(f"Parameter '{parameterName}' is not of type '{expectedType.__name__}'.")
159 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
160 raise ex
162 if parsingDuration is not None and not isinstance(parsingDuration, timedelta):
163 ex = TypeError("Parameter 'parsingDuration' is not of type 'timedelta'.")
164 ex.add_note(f"Got type '{getFullyQualifiedName(parsingDuration)}'.")
165 raise ex
167 self._path = path
168 self._lines = lines
169 self._tree = tree
170 self._tokens = tokens
171 self._parsingDuration = parsingDuration
172 self._positions = {}
173 self._lineStarts = {}
174 self._closings = {}
176 openings: list[int] = []
177 for index, token in enumerate(tokens):
178 self._positions[token.start] = index
179 self._lineStarts.setdefault(token.start[0], index)
180 if token.type == OP:
181 if token.string in ("(", "[", "{"):
182 openings.append(index)
183 elif token.string in (")", "]", "}") and len(openings) > 0:
184 self._closings[openings.pop()] = index
186 @classmethod
187 def Read(cls, path: Path) -> SourceFile:
188 """
189 Read a source file in its declared encoding, and parse it, measuring the duration of both.
191 :param path: The source file.
192 :returns: The read source file.
193 :raises ValueError: If parameter ``path`` is ``None``.
194 :raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path`.
195 :raises OSError: If the file can't be read.
196 :raises SyntaxError: If the file declares an unknown encoding, or isn't valid Python.
197 :raises UnicodeDecodeError: If the file isn't encoded as it declares.
198 :raises ValueError: If the source contains a null byte (Python 3.11).
199 :raises TokenError: If the source ends inside a statement.
200 """
201 if path is None:
202 raise ValueError("Parameter 'path' is None.")
203 elif not isinstance(path, Path):
204 ex = TypeError("Parameter 'path' is not of type 'Path'.")
205 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
206 raise ex
208 with Stopwatch() as stopwatch:
209 with tokenize_open(path) as file:
210 source = file.read()
212 tree = parse(source, filename=str(path))
213 tokens = list(generate_tokens(StringIO(source).readline))
215 parsingDuration = stopwatch.Duration
216 return cls(path, source.split("\n"), tree, tokens, parsingDuration=parsingDuration)
218 @readonly
219 def Path(self) -> Path:
220 """
221 Read-only property to access the path to the source file (:attr:`_path`).
223 :returns: The path.
224 """
225 return self._path
227 @readonly
228 def Lines(self) -> list[str]:
229 """
230 Read-only property to access the source's lines (:attr:`_lines`).
232 :returns: The lines, without line endings.
233 """
234 return self._lines
236 @readonly
237 def Tree(self) -> Module:
238 """
239 Read-only property to access the source's syntax tree (:attr:`_tree`).
241 :returns: The syntax tree.
242 """
243 return self._tree
245 @readonly
246 def ParsingDuration(self) -> Nullable[timedelta]:
247 """
248 Read-only property to access the duration of reading, tokenizing and parsing the file (:attr:`_parsingDuration`).
250 :returns: The duration, or ``None`` if it wasn't measured.
251 """
252 return self._parsingDuration
254 def DocumentationState(self, node: Module | ClassDef | FunctionDef | AsyncFunctionDef) -> CoverageState:
255 """
256 Return how a module, class or function is documented: by a non-empty doc-string, or excused by a comment.
258 docstr_coverage's comment, which excuses a class or function, is the first comment above its declaration,
259 skipping blank lines and the lines of decorators.
261 :param node: The module, class or function.
262 :returns: The documentation state of the declaration.
263 """
264 docString = get_docstring(node)
265 if docString is not None and docString.strip() != "":
266 return CoverageState.Documented | CoverageState.Covered
267 elif isinstance(node, Module):
268 return CoverageState.Undocumented
270 index = self._lineStarts.get(node.lineno, 0) - 1
271 while index >= 0: 271 ↛ 285line 271 didn't jump to line 285 because the condition on line 271 was always true
272 token = self._tokens[index]
273 if token.type in (NL, NEWLINE) or (token.type == NAME and token.string == "class") or \
274 token.line.strip().startswith("@"):
275 index -= 1
276 elif token.type != COMMENT:
277 break
278 elif self._INHERITED.match(token.string) is not None:
279 return CoverageState.Inherited | CoverageState.Covered
280 elif self._EXCUSED.match(token.string) is not None:
281 return CoverageState.Excused | CoverageState.Covered
282 else:
283 break
285 return CoverageState.Undocumented
287 def HasDocumentationComment(self, statement: stmt) -> bool:
288 """
289 Return whether an assignment is documented by a non-empty ``#:`` comment, where Sphinx' autodoc finds it.
291 Like autodoc, the comment behind the statement is looked up first - a comment behind an opening bracket belongs to
292 the value -, then the ``#:`` comment lines directly above the statement.
294 :param statement: The assignment.
295 :returns: ``True``, if a comment documents the assignment.
296 """
297 line = self._lines[statement.lineno - 1]
298 column = len(line.encode("utf-8")[:statement.col_offset].decode("utf-8", errors="replace"))
300 if (index := self._positions.get((statement.lineno, column), None)) is not None: 300 ↛ 316line 300 didn't jump to line 316 because the condition on line 300 was always true
301 token = self._tokens[index]
302 while not (token.type == OP and token.string == "=") and token.type not in (NEWLINE, COMMENT, ENDMARKER):
303 index += 1
304 token = self._tokens[index]
306 if token.type == OP and token.string == "=":
307 index += 1
308 token = self._tokens[index]
309 while token.type in (OP, NAME, NUMBER, STRING) and not (token.type == OP and token.string == ";"):
310 index = self._closings.get(index, index) + 1
311 token = self._tokens[index]
313 if token.type == COMMENT and (match := self._COMMENT.match(token.string)) is not None:
314 return match[1].strip() != ""
316 if line[:column].strip() != "": 316 ↛ 317line 316 didn't jump to line 317 because the condition on line 316 was never true
317 return False
319 for lineIndex in range(statement.lineno - 2, -1, -1): 319 ↛ 325line 319 didn't jump to line 325 because the loop on line 319 didn't complete
320 if (match := self._COMMENT.match(self._lines[lineIndex])) is None:
321 return False
322 elif match[1].strip() != "": 322 ↛ 319line 322 didn't jump to line 319 because the condition on line 322 was always true
323 return True
325 return False
328@export
329class CountedItems(Flag):
330 """
331 The items an analysis counts, besides the packages, modules, classes and callables of a public name.
333 A protected name has one leading underscore; a private name two, but not two trailing ones like a dunder name. An
334 item of a name, which isn't selected, is ignored. Fields and variables, which aren't selected, are excluded.
335 """
337 Fields = 1 #: The fields of a class.
338 Variables = 2 #: The variables of a module.
339 Protected = 4 #: Items of a protected name, e.g. ``_x``.
340 Private = 8 #: Items of a private, name-mangled name, e.g. ``__x``.
342 Default = Fields | Variables | Protected | Private #: All items.
345# A class with a property named like a class - ``CountedItems`` - can't name that class in the annotation of a field:
346# the class body's namespace, where annotations are evaluated, binds the name to the property.
347_CountedItems = CountedItems
350@export
351class Analyzer(metaclass=ExtendedType, slots=True):
352 """
353 Reads the documentation coverage of a Python package from its source files.
355 A package's or module's lines are its file's, so their items name no lines.
357 .. rubric:: Example
359 .. code-block:: Python
361 from pathlib import Path
362 from pyEDAA.Reports.DocumentationCoverage.Python import Analyzer, CountedItems
363 from pyTooling.Warning import WarningCollector
365 with WarningCollector() as warnings:
366 counted = CountedItems.Fields | CountedItems.Variables
367 report = Analyzer("myPackage", Path("src/myPackage"), countedItems=counted).Analyze()
369 print(f"{report.Counters.Covered}/{report.Counters.Expected}: {report.Counters.Coverage:.1%}")
370 """
372 _packageName: str #: Name of the package.
373 _directory: Path #: The package's directory.
374 _countedItems: _CountedItems #: The items the analysis counts.
375 _excludePatterns: list[Pattern[str]] #: The patterns of qualified names, whose items are excluded.
377 def __init__(
378 self,
379 packageName: str,
380 directory: Path,
381 *,
382 countedItems: CountedItems = CountedItems.Default,
383 excludePatterns: Nullable[Iterable[str]] = None
384 ) -> None:
385 """
386 Initializes the analysis of a package.
388 :param packageName: Name of the package, which is the name of the top-level item, e.g. ``myPackage``.
389 :param directory: The package's directory.
390 :param countedItems: Optional, the items the analysis counts. Default: all of them.
391 :param excludePatterns: Optional, regular expressions matching the whole qualified name of an item to exclude,
392 e.g. ``myPackage\\.CLI``; the items it contains are excluded too. Default: none.
393 :raises ValueError: If parameter ``packageName`` is ``None``.
394 :raises TypeError: If parameter ``packageName`` isn't of type :class:`str`.
395 :raises ValueError: If parameter ``packageName`` is empty.
396 :raises ValueError: If parameter ``directory`` is ``None``.
397 :raises TypeError: If parameter ``directory`` isn't of type :class:`~pathlib.Path`.
398 :raises ValueError: If parameter ``countedItems`` is ``None``.
399 :raises TypeError: If parameter ``countedItems`` isn't of type :class:`CountedItems`.
400 :raises TypeError: If parameter ``excludePatterns`` isn't iterable.
401 :raises TypeError: If parameter ``excludePatterns`` contains an element not of type :class:`str`.
402 :raises ValueError: If parameter ``excludePatterns`` contains an invalid regular expression.
403 """
404 if packageName is None: 404 ↛ 405line 404 didn't jump to line 405 because the condition on line 404 was never true
405 raise ValueError("Parameter 'packageName' is None.")
406 elif not isinstance(packageName, str): 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true
407 ex = TypeError("Parameter 'packageName' is not of type 'str'.")
408 ex.add_note(f"Got type '{getFullyQualifiedName(packageName)}'.")
409 raise ex
410 elif packageName == "":
411 raise ValueError("Parameter 'packageName' is empty.")
413 if directory is None: 413 ↛ 414line 413 didn't jump to line 414 because the condition on line 413 was never true
414 raise ValueError("Parameter 'directory' is None.")
415 elif not isinstance(directory, Path):
416 ex = TypeError("Parameter 'directory' is not of type 'Path'.")
417 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.")
418 raise ex
420 if countedItems is None:
421 raise ValueError("Parameter 'countedItems' is None.")
422 elif not isinstance(countedItems, CountedItems):
423 ex = TypeError("Parameter 'countedItems' is not of type 'CountedItems'.")
424 ex.add_note(f"Got type '{getFullyQualifiedName(countedItems)}'.")
425 raise ex
427 self._packageName = packageName
428 self._directory = directory
429 self._countedItems = countedItems
430 self._excludePatterns = []
432 if excludePatterns is not None:
433 if not isinstance(excludePatterns, Iterable):
434 ex = TypeError("Parameter 'excludePatterns' is not iterable.")
435 ex.add_note(f"Got type '{getFullyQualifiedName(excludePatterns)}'.")
436 raise ex
438 for excludePattern in excludePatterns:
439 if not isinstance(excludePattern, str):
440 ex = TypeError("Parameter 'excludePatterns' contains an element not of type 'str'.")
441 ex.add_note(f"Got type '{getFullyQualifiedName(excludePattern)}'.")
442 raise ex
444 try:
445 self._excludePatterns.append(re_compile(excludePattern))
446 except RegularExpressionError as cause:
447 ex = ValueError("Parameter 'excludePatterns' contains an invalid regular expression.")
448 ex.add_note(f"Got pattern '{excludePattern}'.")
449 raise ex from cause
451 @readonly
452 def PackageName(self) -> str:
453 """
454 Read-only property to access the name of the package (:attr:`_packageName`).
456 :returns: The package's name.
457 """
458 return self._packageName
460 @readonly
461 def Directory(self) -> Path:
462 """
463 Read-only property to access the package's directory (:attr:`_directory`).
465 :returns: The directory.
466 """
467 return self._directory
469 @readonly
470 def CountedItems(self) -> CountedItems:
471 """
472 Read-only property to access the items the analysis counts (:attr:`_countedItems`).
474 :returns: The counted items.
475 """
476 return self._countedItems
478 @readonly
479 def ExcludePatterns(self) -> list[Pattern[str]]:
480 """
481 Read-only property to access the patterns of qualified names, whose items are excluded (:attr:`_excludePatterns`).
483 :returns: The compiled regular expressions.
484 """
485 return self._excludePatterns
487 def Analyze(self) -> DocumentationReport:
488 """
489 Read every Python source file of the package into a documentation coverage report, and aggregate it.
491 The report's :attr:`~pyEDAA.Reports.DocumentationCoverage.DocumentationReport.AnalysisDuration` is the
492 duration of the analysis. A source file, which can't be read or parsed, is raised as a
493 :exc:`~pyEDAA.Reports.DocumentationCoverage.SourceFileWarning` to the current
494 :class:`~pyTooling.Warning.WarningCollector`; its module is excluded, and the analysis continues.
496 :returns: The report; its top-level item is the package.
497 :raises DocCoverageError: If the package's directory doesn't exist.
498 :raises UnhandledCriticalWarningError: If a source file can't be analyzed, and no warning
499 collector collects the warning. |br|
500 Add a with-statement using
501 :class:`~pyTooling.Warning.WarningCollector`.
502 """
503 if not self._directory.is_dir():
504 raise DocCoverageError(f"Package directory '{self._directory.as_posix()}' doesn't exist.")
506 with Stopwatch() as stopwatch:
507 report = DocumentationReport(Path(self._packageName))
508 self._ReadPackage(self._directory, self._packageName, report, report)
509 report.Aggregate()
511 report._analysisDuration = stopwatch.Duration
513 return report
515 def _ReadPackage(
516 self,
517 directory: Path,
518 name: str,
519 parent: Item | DocumentationReport,
520 report: DocumentationReport
521 ) -> None:
522 """
523 Read a package's ``__init__.py``, its modules, then its sub-packages, each sorted by name.
525 :param directory: The package's directory.
526 :param name: The package's name.
527 :param parent: The package containing this one, or the report.
528 :param report: The report.
529 """
530 if (directory / "__init__.py").is_file():
531 package = self._ReadModule(directory / "__init__.py", name, ItemKind.Package, parent, report)
532 else:
533 package = Item(name, ItemKind.Package, self._State(CoverageState.Empty, name, parent), parent=parent)
535 paths = sorted(directory.iterdir(), key=Path.as_posix)
536 for path in paths:
537 if path.suffix == ".py" and path.name != "__init__.py" and path.is_file():
538 self._ReadModule(path, path.stem, ItemKind.Module, package, report)
540 for path in paths:
541 if path.is_dir() and next(path.rglob("*.py"), None) is not None:
542 self._ReadPackage(path, path.name, package, report)
544 def _ReadModule(
545 self,
546 path: Path,
547 name: str,
548 kind: ItemKind,
549 parent: Item | DocumentationReport,
550 report: DocumentationReport
551 ) -> Item:
552 """
553 Read a module, or a package's ``__init__.py``, and the items declared in it.
555 :param path: The source file.
556 :param name: The module's or package's name.
557 :param kind: The kind of the module's item.
558 :param parent: The package containing the module, or the report.
559 :param report: The report.
560 :returns: The module's or package's item.
561 :raises UnhandledCriticalWarningError: If the source file can't be analyzed, and no warning
562 collector collects the warning.
563 """
564 file = report.GetOrAddFile(path.relative_to(self._directory))
565 try:
566 sourceFile = SourceFile.Read(path)
567 except (OSError, SyntaxError, TokenError, ValueError) as ex:
568 WarningCollector.Raise(
569 SourceFileWarning(f"Source file '{file.Path.as_posix()}' can't be analyzed.", file=file),
570 ex
571 )
572 return Item(name, kind, CoverageState.Excluded, file=file, parent=parent)
574 if len(sourceFile.Tree.body) == 0:
575 documentation = CoverageState.Empty
576 else:
577 documentation = sourceFile.DocumentationState(sourceFile.Tree)
579 module = Item(name, kind, self._State(documentation, name, parent), file=file, parent=parent)
580 self._ReadBody(sourceFile, sourceFile.Tree.body, module, False)
582 return module
584 def _ReadBody(self, sourceFile: SourceFile, body: list[stmt], parent: Item, inFunction: bool) -> None:
585 """
586 Read the items declared in a module's, class' or function's body, in the order of their first declaration.
588 Classes and functions are read wherever they are declared. Fields and variables are read from a module's or class'
589 body and the ``if`` and ``try`` statements in it, but not from a function's body or a class declared in it. An
590 assignment to a field in the class' ``__init__`` documents the field, if commented.
592 :param sourceFile: The source file.
593 :param body: The statements of the body.
594 :param parent: The module's, class' or function's item.
595 :param inFunction: Whether the body is a function's, or is declared in a function's body.
596 """
597 order: list[stmt | str] = []
598 first: dict[str, stmt] = {}
599 documented: dict[str, bool] = {}
600 previous: Nullable[AST] = None
602 def visit(statements: list[stmt], readAttributes: bool, selfName: str) -> None:
603 """
604 Nested function for recursion.
606 The statement before an expression is tracked as Sphinx' autodoc does: a string after an assignment documents
607 it; the condition of an ``if`` statement is the statement before its first block.
609 :param statements: The statements of a block.
610 :param readAttributes: Whether the block's fields or variables are read.
611 :param selfName: Empty for a module's or class' body. In the body of a class' ``__init__``: the name of
612 its first parameter, whose attributes document the class' fields.
613 """
614 nonlocal previous
615 for statement in statements:
616 if isinstance(statement, (ClassDef, FunctionDef, AsyncFunctionDef)):
617 if selfName == "": 617 ↛ 652line 617 didn't jump to line 652 because the condition on line 617 was always true
618 order.append(statement)
619 elif readAttributes and len(names := self._TargetNames(statement, False, selfName)) > 0:
620 hasComment = sourceFile.HasDocumentationComment(statement)
621 for name in names:
622 if selfName == "" and name not in first:
623 order.append(name)
624 first[name] = statement
625 documented[name] = False
627 if name in documented:
628 documented[name] = documented[name] or hasComment
629 elif isinstance(statement, Expr) and isinstance(statement.value, Constant) and \
630 isinstance(statement.value.value, str) and statement.value.value.strip() != "":
631 for name in self._TargetNames(previous, True, selfName):
632 if name in documented: 632 ↛ 631line 632 didn't jump to line 631 because the condition on line 632 was always true
633 documented[name] = True
634 elif isinstance(statement, If):
635 previous = statement.test
636 visit(statement.body, readAttributes, selfName)
637 visit(statement.orelse, readAttributes, selfName)
638 elif isinstance(statement, Try):
639 visit(statement.body, readAttributes, selfName)
640 visit(statement.orelse, readAttributes, selfName)
641 for handler in statement.handlers:
642 visit(handler.body, False, selfName)
644 visit(statement.finalbody, False, selfName)
645 else:
646 for block in ("body", "orelse", "finalbody"):
647 visit(getattr(statement, block, []), False, selfName)
649 for clause in (*getattr(statement, "handlers", []), *getattr(statement, "cases", [])): 649 ↛ 650line 649 didn't jump to line 650 because the loop on line 649 never started
650 visit(clause.body, False, selfName)
652 previous = statement
654 isContainer = parent._kind in (ItemKind.Package, ItemKind.Module, ItemKind.Class, ItemKind.Enumeration)
655 visit(body, isContainer and not inFunction, "")
657 if parent._kind in (ItemKind.Class, ItemKind.Enumeration) and not inFunction:
658 for entry in [entry for entry in order if isinstance(entry, (FunctionDef, AsyncFunctionDef))]:
659 if entry.name == "__init__" and len(parameters := (*entry.args.posonlyargs, *entry.args.args)) > 0:
660 previous = entry
661 visit(entry.body, True, parameters[0].arg)
663 for entry in order:
664 if isinstance(entry, ClassDef):
665 self._ReadClass(sourceFile, entry, parent, inFunction)
666 elif isinstance(entry, (FunctionDef, AsyncFunctionDef)):
667 self._ReadFunction(sourceFile, entry, parent)
668 else:
669 self._ReadAttribute(entry, first[entry], documented[entry], parent)
671 def _ReadClass(self, sourceFile: SourceFile, statement: ClassDef, parent: Item, inFunction: bool) -> None:
672 """
673 Read a class, and the items declared in its body.
675 A class deriving from a class named ``...Enum`` or ``...Flag`` is an enumeration.
677 :param sourceFile: The source file.
678 :param statement: The class' declaration.
679 :param parent: The item containing the class.
680 :param inFunction: Whether the class is declared in a function's body.
681 """
682 if any(self._BaseName(base).endswith(("Enum", "Flag")) for base in statement.bases):
683 kind = ItemKind.Enumeration
684 else:
685 kind = ItemKind.Class
687 item = Item(
688 statement.name,
689 kind,
690 self._State(sourceFile.DocumentationState(statement), statement.name, parent),
691 file=parent._file,
692 startLine=self._FirstLine(statement),
693 endLine=statement.end_lineno,
694 parent=parent
695 )
696 self._ReadBody(sourceFile, statement.body, item, inFunction)
698 def _ReadFunction(self, sourceFile: SourceFile, statement: FunctionDef | AsyncFunctionDef, parent: Item) -> None:
699 """
700 Read a function, method, constructor or property, and the classes and functions declared in its body.
702 A property's setter and deleter and an ``@overload`` stub are ignored.
704 :param sourceFile: The source file.
705 :param statement: The function's declaration.
706 :param parent: The item containing the function.
707 """
708 decorators = {self._BaseName(decorator) for decorator in statement.decorator_list}
709 isAccessor = any(
710 isinstance(decorator, Attribute) and decorator.attr in ("setter", "deleter")
711 for decorator in statement.decorator_list
712 )
713 isMember = parent._kind in (ItemKind.Class, ItemKind.Enumeration)
715 if isAccessor or (isMember and not decorators.isdisjoint(("property", "readonly", "cached_property", "getter"))):
716 kind = ItemKind.Property
717 elif isMember and statement.name == "__init__":
718 kind = ItemKind.Constructor
719 elif isMember and statement.name == "__del__":
720 kind = ItemKind.Destructor
721 elif isMember:
722 kind = ItemKind.Method
723 else:
724 kind = ItemKind.Function
726 item = Item(
727 statement.name,
728 kind,
729 self._State(
730 sourceFile.DocumentationState(statement),
731 statement.name,
732 parent,
733 ignored=isAccessor or "overload" in decorators
734 ),
735 file=parent._file,
736 startLine=self._FirstLine(statement),
737 endLine=statement.end_lineno,
738 parent=parent
739 )
740 self._ReadBody(sourceFile, statement.body, item, True)
742 def _ReadAttribute(self, name: str, statement: stmt, documented: bool, parent: Item) -> None:
743 """
744 Read a field of a class or a variable of a module, declared first by a statement.
746 A variable assigned a call of ``TypeVar``, ``ParamSpec`` or ``TypeVarTuple`` is a type variable, of ``NewType`` a
747 type; a variable annotated ``Final`` or named in upper case is a constant.
749 :param name: The field's or variable's name.
750 :param statement: The first statement assigning or annotating the name.
751 :param documented: Whether a statement assigning or annotating the name documents it.
752 :param parent: The class' or module's item.
753 """
754 isField = parent._kind in (ItemKind.Class, ItemKind.Enumeration)
755 annotationName = self._BaseName(statement.annotation) if isinstance(statement, AnnAssign) else ""
756 callName = self._BaseName(statement.value.func) if isinstance(getattr(statement, "value", None), Call) else ""
758 if (version_info >= (3, 12) and isinstance(statement, TypeAlias)) or annotationName == "TypeAlias":
759 kind = ItemKind.TypeAlias
760 elif isField and parent._kind is ItemKind.Enumeration and isinstance(statement, Assign):
761 kind = ItemKind.EnumerationMember
762 elif isField:
763 kind = ItemKind.Field
764 elif callName in ("TypeVar", "ParamSpec", "TypeVarTuple"):
765 kind = ItemKind.TypeVariable
766 elif callName == "NewType":
767 kind = ItemKind.Type
768 elif annotationName == "Final" or name.isupper():
769 kind = ItemKind.Constant
770 else:
771 kind = ItemKind.Variable
773 Item(
774 name,
775 kind,
776 self._State(
777 CoverageState.Documented | CoverageState.Covered if documented else CoverageState.Undocumented,
778 name,
779 parent,
780 ignored=name.startswith("_") and name.endswith("_"),
781 excluded=(CountedItems.Fields if isField else CountedItems.Variables) not in self._countedItems
782 ),
783 file=parent._file,
784 startLine=statement.lineno,
785 endLine=statement.end_lineno,
786 parent=parent
787 )
789 def _State(
790 self,
791 documentation: CoverageState,
792 name: str,
793 parent: Item | DocumentationReport,
794 *,
795 ignored: bool = False,
796 excluded: bool = False
797 ) -> CoverageState:
798 """
799 Return an item's coverage state: how it is documented, and whether it is excluded or ignored.
801 An item is excluded, if its qualified name, or the qualified name of an item containing it, matches an exclude
802 pattern. It is ignored, if its name is protected or private, and :class:`CountedItems` doesn't select such names.
804 :param documentation: How the item is documented.
805 :param name: The item's name.
806 :param parent: The item's parent.
807 :param ignored: Optional, whether the reader's rules ignore the item. Default: ``False``.
808 :param excluded: Optional, whether the options exclude the item. Default: ``False``.
809 :returns: The coverage state.
810 """
811 names = f"{parent.QualifiedName}.{name}".split(".") if isinstance(parent, Item) else name.split(".")
812 if excluded or any(
813 pattern.fullmatch(".".join(names[:length])) is not None
814 for pattern in self._excludePatterns for length in range(1, len(names) + 1)
815 ):
816 return documentation | CoverageState.Excluded
817 elif ignored or (
818 name.startswith("_") and not name.startswith("__") and CountedItems.Protected not in self._countedItems
819 ) or (
820 name.startswith("__") and not name.endswith("__") and CountedItems.Private not in self._countedItems
821 ):
822 return documentation | CoverageState.Ignored
824 return documentation
826 @staticmethod
827 def _TargetNames(statement: Nullable[AST], firstTargetOnly: bool, selfName: str) -> list[str]:
828 """
829 Return the names an assignment, an annotation or a ``type`` statement declares, as Sphinx' autodoc reads them.
831 In a module's or class' body, a name target declares a name. In the body of a class' ``__init__``, an attribute
832 of its first parameter - ``self._x`` - declares the attribute's name.
834 :param statement: The statement; any other statement or ``None`` declares no name.
835 :param firstTargetOnly: Whether only the first target of a chained assignment counts.
836 :param selfName: Empty for a module's or class' body; the name of ``__init__``'s first parameter in its body.
837 :returns: The names; none, if a target declares no name, e.g. a subscript.
838 """
839 if isinstance(statement, Assign):
840 targets = statement.targets[:1] if firstTargetOnly else statement.targets
841 elif isinstance(statement, AnnAssign):
842 targets = [statement.target]
843 elif version_info >= (3, 12) and isinstance(statement, TypeAlias):
844 targets = [statement.name]
845 else:
846 return []
848 def targetName(target: expr) -> str:
849 """
850 Nested function returning the name a target - neither a tuple nor a list - declares.
852 :param target: The target.
853 :returns: The name; empty, if the target declares none.
854 """
855 if isinstance(target, Name) and (selfName == "" or target.id == selfName):
856 return target.id
857 elif isinstance(target, Attribute) and selfName != "" and isinstance(target.value, Name) and \
858 target.value.id == selfName:
859 return target.attr
861 return ""
863 names: list[str] = []
864 for target in targets:
865 if isinstance(target, (Tuple, List)):
866 for element in target.elts:
867 if (name := targetName(element.value if isinstance(element, Starred) else element)) != "": 867 ↛ 866line 867 didn't jump to line 866 because the condition on line 867 was always true
868 names.append(name)
869 elif (name := targetName(target)) != "":
870 names.append(name)
871 else:
872 return []
874 return names
876 @staticmethod
877 def _BaseName(expression: expr) -> str:
878 """
879 Return the name an expression is based on: of a name, an attribute, a subscripted or a called expression.
881 ``typing.ClassVar[int]`` is based on ``ClassVar``, ``functools.cached_property`` on ``cached_property``.
883 :param expression: The expression.
884 :returns: The name; empty, if the expression isn't based on a name.
885 """
886 if isinstance(expression, Name):
887 return expression.id
888 elif isinstance(expression, Attribute):
889 return expression.attr
890 elif isinstance(expression, Subscript):
891 return Analyzer._BaseName(expression.value)
892 elif isinstance(expression, Call): 892 ↛ 895line 892 didn't jump to line 895 because the condition on line 892 was always true
893 return Analyzer._BaseName(expression.func)
895 return ""
897 @staticmethod
898 def _FirstLine(statement: ClassDef | FunctionDef | AsyncFunctionDef) -> int:
899 """
900 Return a declaration's first line: its first decorator's, if it has decorators.
902 :param statement: The declaration.
903 :returns: The line number.
904 """
905 return statement.decorator_list[0].lineno if len(statement.decorator_list) > 0 else statement.lineno