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

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. 

33 

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. 

38 

39.. rubric:: Items 

40 

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. 

50 

51.. rubric:: Documentation 

52 

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. 

59 

60.. rubric:: Ignored and excluded items 

61 

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 

70 

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 

83 

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 

89 

90from pyEDAA.Reports.DocumentationCoverage import CoverageState, DocCoverageError, DocumentationReport, Item, ItemKind, \ 

91 SourceFileWarning 

92 

93if version_info >= (3, 12): # pragma: no cover 

94 from ast import TypeAlias 

95 

96 

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 

100 

101 

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 """ 

107 

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. 

111 

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. 

120 

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. 

132 

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 

160 

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 

165 

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 = {} 

174 

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 

184 

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. 

189 

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 

206 

207 with Stopwatch() as stopwatch: 

208 with tokenize_open(path) as file: 

209 source = file.read() 

210 

211 tree = parse(source, filename=str(path)) 

212 tokens = list(generate_tokens(StringIO(source).readline)) 

213 

214 parsingDuration = stopwatch.Duration 

215 return cls(path, source.split("\n"), tree, tokens, parsingDuration=parsingDuration) 

216 

217 @readonly 

218 def Path(self) -> Path: 

219 """ 

220 Read-only property to access the path to the source file (:attr:`_path`). 

221 

222 :returns: The path. 

223 """ 

224 return self._path 

225 

226 @readonly 

227 def Lines(self) -> list[str]: 

228 """ 

229 Read-only property to access the source's lines (:attr:`_lines`). 

230 

231 :returns: The lines, without line endings. 

232 """ 

233 return self._lines 

234 

235 @readonly 

236 def Tree(self) -> Module: 

237 """ 

238 Read-only property to access the source's syntax tree (:attr:`_tree`). 

239 

240 :returns: The syntax tree. 

241 """ 

242 return self._tree 

243 

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`). 

248 

249 :returns: The duration, or ``None`` if it wasn't measured. 

250 """ 

251 return self._parsingDuration 

252 

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. 

256 

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. 

259 

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 

268 

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 

283 

284 return CoverageState.Undocumented 

285 

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. 

289 

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. 

292 

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")) 

298 

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] 

304 

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] 

311 

312 if token.type == COMMENT and (match := self._COMMENT.match(token.string)) is not None: 

313 return match[1].strip() != "" 

314 

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 

317 

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 

323 

324 return False 

325 

326 

327@export 

328class CountedItems(Flag): 

329 """ 

330 The items an analysis counts, besides the packages, modules, classes and callables of a public name. 

331 

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 """ 

335 

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``. 

340 

341 Default = Fields | Variables | Protected | Private #: All items. 

342 

343 

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 

347 

348 

349@export 

350class Analyzer(metaclass=ExtendedType, slots=True): 

351 """ 

352 Reads the documentation coverage of a Python package from its source files. 

353 

354 A package's or module's lines are its file's, so their items name no lines. 

355 

356 .. rubric:: Example 

357 

358 .. code-block:: Python 

359 

360 from pathlib import Path 

361 from pyEDAA.Reports.DocumentationCoverage.Python import Analyzer, CountedItems 

362 from pyTooling.Warning import WarningCollector 

363 

364 with WarningCollector() as warnings: 

365 counted = CountedItems.Fields | CountedItems.Variables 

366 report = Analyzer("myPackage", Path("src/myPackage"), countedItems=counted).Analyze() 

367 

368 print(f"{report.Counters.Covered}/{report.Counters.Expected}: {report.Counters.Coverage:.1%}") 

369 """ 

370 

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. 

375 

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. 

386 

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.") 

411 

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 

418 

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 

425 

426 self._packageName = packageName 

427 self._directory = directory 

428 self._countedItems = countedItems 

429 self._excludePatterns = excludePatternList = [] 

430 

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 

436 

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 

442 

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 

449 

450 @readonly 

451 def PackageName(self) -> str: 

452 """ 

453 Read-only property to access the name of the package (:attr:`_packageName`). 

454 

455 :returns: The package's name. 

456 """ 

457 return self._packageName 

458 

459 @readonly 

460 def Directory(self) -> Path: 

461 """ 

462 Read-only property to access the package's directory (:attr:`_directory`). 

463 

464 :returns: The directory. 

465 """ 

466 return self._directory 

467 

468 @readonly 

469 def CountedItems(self) -> CountedItems: 

470 """ 

471 Read-only property to access the items the analysis counts (:attr:`_countedItems`). 

472 

473 :returns: The counted items. 

474 """ 

475 return self._countedItems 

476 

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`). 

481 

482 :returns: The compiled regular expressions. 

483 """ 

484 return self._excludePatterns 

485 

486 def Analyze(self) -> DocumentationReport: 

487 """ 

488 Read every Python source file of the package into a documentation coverage report, and aggregate it. 

489 

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. 

494 

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.") 

504 

505 with Stopwatch() as stopwatch: 

506 report = DocumentationReport(Path(self._packageName)) 

507 self._ReadPackage(self._directory, self._packageName, report, report) 

508 report.Aggregate() 

509 

510 report._analysisDuration = stopwatch.Duration 

511 

512 return report 

513 

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. 

523 

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) 

533 

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) 

538 

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) 

542 

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. 

553 

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) 

572 

573 if len(sourceFile.Tree.body) == 0: 

574 documentation = CoverageState.Empty 

575 else: 

576 documentation = sourceFile.DocumentationState(sourceFile.Tree) 

577 

578 module = Item(name, kind, self._State(documentation, name, parent), file=file, parent=parent) 

579 self._ReadBody(sourceFile, sourceFile.Tree.body, module, False) 

580 

581 return module 

582 

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. 

586 

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. 

590 

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 

600 

601 def visit(statements: list[stmt], readAttributes: bool, selfName: str) -> None: 

602 """ 

603 Nested function for recursion. 

604 

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. 

607 

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 

625 

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) 

642 

643 visit(statement.finalbody, False, selfName) 

644 else: 

645 for block in ("body", "orelse", "finalbody"): 

646 visit(getattr(statement, block, []), False, selfName) 

647 

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) 

650 

651 previous = statement 

652 

653 isContainer = parent._kind in (ItemKind.Package, ItemKind.Module, ItemKind.Class, ItemKind.Enumeration) 

654 visit(body, isContainer and not inFunction, "") 

655 

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) 

661 

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) 

669 

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. 

673 

674 A class deriving from a class named ``...Enum`` or ``...Flag`` is an enumeration. 

675 

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 

685 

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) 

696 

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. 

700 

701 A property's setter and deleter and an ``@overload`` stub are ignored. 

702 

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) 

713 

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 

724 

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) 

740 

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. 

744 

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. 

747 

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 "" 

756 

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 

771 

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 ) 

787 

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. 

799 

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. 

802 

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 

822 

823 return documentation 

824 

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. 

829 

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. 

832 

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 [] 

846 

847 def targetName(target: expr) -> str: 

848 """ 

849 Nested function returning the name a target - neither a tuple nor a list - declares. 

850 

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 

859 

860 return "" 

861 

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 [] 

872 

873 return names 

874 

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. 

879 

880 ``typing.ClassVar[int]`` is based on ``ClassVar``, ``functools.cached_property`` on ``cached_property``. 

881 

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) 

893 

894 return "" 

895 

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. 

900 

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