Coverage for pyEDAA/Reports/DocumentationCoverage/__init__.py: 100%
503 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 2021-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"""
32Abstract data model of documentation coverage, the common model every language's reader fills.
34The model has two hierarchies over the same documentable items:
36* The **physical** hierarchy - :class:`DocumentationReport`, :class:`Directory` and :class:`File` - is built from the
37 source files a reader analyzed. A file counts the items declared in it; a directory sums its directories and files.
38* The **logical** hierarchy holds the documentable items of a language - :class:`Item` -, each with its kind, its
39 documentation state, its source file and lines, and the items it contains. The report holds its top-level items too.
41An item - a package, a class, a method, a field, a VHDL entity, ... - names the source file it is declared in and its
42first and last line, where the reader knows them.
44An item's :class:`ItemKind` names the language construct, e.g. a class, a method or a VHDL entity. Each kind belongs to
45an :class:`ItemCategory` shared by all languages - containers, types, callables, fields, variables, parameters -, so
46the counters can be broken down the same way for every language.
48An item's :class:`CoverageState` says, whether it is documented, and whether it is expected to be. The counters
49(:class:`CoverageCounters`) of an item, a file, a directory or the whole analysis are computed from these states by
50``Aggregate()``.
52.. seealso::
54 :mod:`pyEDAA.Reports.DocumentationCoverage.Python`
55 |rarr| The reader of Python source files.
56 :mod:`pyEDAA.Reports.DocumentationCoverage.DocStrCoverage`
57 |rarr| The converter of docstr_coverage's analysis.
58"""
59from __future__ import annotations
61from datetime import timedelta
62from enum import Enum, Flag
63from pathlib import Path
64from typing import Callable, ClassVar, Generator, Iterator, Optional as Nullable
66from pyTooling.Common import getFullyQualifiedName, getTypeName
67from pyTooling.Decorators import export, readonly
68from pyTooling.Exceptions import AlreadyInHierarchyError, DuplicateElementError
69from pyTooling.MetaClasses import ExtendedType, abstractmethod
70from pyTooling.Warning import CriticalWarning
72from pyEDAA.Reports import ReportException
75@export
76class DocCoverageError(ReportException):
77 """Base-exception of the documentation coverage data model and its readers."""
80@export
81class AlreadyInDocumentationReportError(DocCoverageError, AlreadyInHierarchyError):
82 """The exception is raised when an element is added to an element, but it already has a parent."""
85@export
86class DuplicateDirectoryError(DocCoverageError, DuplicateElementError):
87 """The exception is raised when a directory already contains a directory or a file of the same name."""
90@export
91class DuplicateFileError(DocCoverageError, DuplicateElementError):
92 """The exception is raised when a directory or a report already contains a file or a directory of the same name."""
95@export
96class SourceFileWarning(CriticalWarning):
97 """
98 A source file can't be analyzed: it can't be read, decoded or parsed.
100 The reader excludes the file's item from the analysis, and continues with the next file. The cause is the exception
101 reading, decoding or parsing raised.
103 .. important::
105 This warning is critical: it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
106 call-hierarchy, otherwise :meth:`~pyTooling.Warning.WarningCollector.Raise` raises an
107 :exc:`~pyTooling.Warning.UnhandledCriticalWarningError`.
108 """
110 _file: Nullable[_File] #: The report's file of the source file.
112 def __init__(self, message: str, /, *, file: Nullable[_File] = None) -> None:
113 """
114 Initializes the warning with the report's file of the source file that can't be analyzed.
116 :param message: The warning's message.
117 :param file: Optional, the report's file of the source file.
118 """
119 super().__init__(message)
120 self._file = file
122 @readonly
123 def File(self) -> Nullable[_File]:
124 """
125 Read-only property to access the report's file of the source file that can't be analyzed (:attr:`_file`).
127 Its path, relative to the analyzed directory, is
128 :attr:`File.Path <pyEDAA.Reports.DocumentationCoverage.File.Path>`.
130 :returns: The file, or ``None`` if it wasn't recorded.
131 """
132 return self._file
135@export
136class CoverageState(Flag):
137 """
138 The documentation state of an item, as flags.
140 :class:`CoverageCounters` counts an item by the first of these flags it has:
142 * :attr:`Empty`: not counted.
143 * :attr:`Excluded`: counted as excluded.
144 * :attr:`Ignored`: counted as ignored.
145 * :attr:`Covered`: counted as expected and covered.
146 * None of them: counted as expected and uncovered.
147 """
149 Unknown = 0 #: No state; an item can't have it.
150 Excluded = 1 #: Excluded from the analysis, e.g. by a name pattern.
151 Ignored = 2 #: Needs no documentation by the reader's rules, e.g. a property's setter.
152 Empty = 4 #: Has nothing to document, e.g. an empty module; not counted.
153 Covered = 8 #: Counts as documented.
155 Weak = 16 #: Documented, but too briefly.
156 Incomplete = 32 #: Documented, but not every part of it.
157 Inherited = 64 #: Undocumented, but takes the documentation of what it overrides, as a comment states.
158 Detailed = 128 #: Documented in detail.
160 Undocumented = 256 #: Has no documentation.
161 Documented = 512 #: Has documentation.
163 Parameters = 1024 #: Its parameters are documented.
164 ReturnValue = 2048 #: Its return value is documented.
165 Exceptions = 8192 #: The exceptions it raises are documented.
166 Types = 16384 #: Its types are documented.
168 Excused = 32768 #: Undocumented, but a comment excuses the missing documentation.
171@export
172class ItemCategory(Enum):
173 """
174 The category of a documentable item, shared by all languages.
176 Each :class:`ItemKind` belongs to one category (:attr:`ItemKind.Category`), so the counters of every language break
177 down the same way.
178 """
180 Container = 1 #: Contains other items, e.g. a package, a module, a namespace or a VHDL entity.
181 Type = 2 #: Declares a type, e.g. a class, a struct, an enumeration or a type alias.
182 Callable = 3 #: Can be called, e.g. a function, a method, a procedure or a property.
183 Field = 4 #: Data of a type, e.g. a class' field, a record's element or an enumeration's member.
184 Variable = 5 #: Data of a container, whose value can change, e.g. a module's variable or a VHDL signal.
185 Constant = 6 #: Data of a container, whose value can't change, e.g. a constant of C or VHDL.
186 Generic = 7 #: A compile-time parameter, e.g. a VHDL generic or a C++ template parameter.
187 Parameter = 8 #: A run-time parameter declared as an item of its own, e.g. a VHDL port.
188 Other = 9 #: Belongs to no other category.
190 DEFAULT = Other #: The category of an item, which belongs to no other category.
193@export
194class ItemKind(Enum):
195 """
196 The kind of a documentable item: the language construct it is.
198 Languages share a kind, where they share the construct, e.g. a package of Python, Java and Go. A construct only one
199 language has, has a kind of its own, e.g. a VHDL entity. Each kind belongs to an :class:`ItemCategory`
200 (:attr:`Category`).
201 """
203 Library = 1 #: A library of design units, e.g. a VHDL library.
204 Package = 2 #: A package, e.g. of Python, Java or Go, or a VHDL package.
205 PackageBody = 3 #: The body of a VHDL package.
206 Namespace = 4 #: A namespace, e.g. of C++ or TCL.
207 Module = 5 #: A module, e.g. a Python module.
208 SourceFile = 6 #: A source file, where the file is the language's unit, e.g. in C.
209 Entity = 7 #: A VHDL entity.
210 Architecture = 8 #: A VHDL architecture.
211 Configuration = 9 #: A VHDL configuration.
212 Context = 10 #: A VHDL context.
214 Class = 11 #: A class.
215 Struct = 12 #: A structure, e.g. of C, C++ or Go.
216 Union = 13 #: A union of C or C++.
217 Interface = 14 #: An interface, e.g. of Java or Go.
218 Enumeration = 15 #: An enumeration.
219 Record = 16 #: A record, e.g. of Java or a VHDL record type.
220 Type = 17 #: Any other type declaration, e.g. a VHDL type or subtype.
221 TypeAlias = 18 #: A type alias, e.g. a C ``typedef`` or Python's ``type`` statement.
222 TypeVariable = 19 #: A type variable, e.g. Python's ``TypeVar``.
223 Component = 20 #: A VHDL component declaration.
225 Function = 21 #: A function.
226 Method = 22 #: A method of a type.
227 Constructor = 23 #: A type's constructor, e.g. Python's ``__init__``.
228 Destructor = 24 #: A type's destructor, e.g. of C++, or Python's ``__del__``.
229 Property = 25 #: A property: a method accessed like a field.
230 Procedure = 26 #: A procedure, e.g. of VHDL or TCL.
231 Macro = 27 #: A preprocessor macro, e.g. of C.
232 Process = 28 #: A VHDL process.
234 Field = 29 #: A field of a type, e.g. a class' field or a record's element.
235 EnumerationMember = 30 #: A member of an enumeration.
237 Variable = 31 #: A variable of a container, e.g. a module's variable.
238 Signal = 32 #: A VHDL signal.
239 FileObject = 33 #: A file object, e.g. declared by a VHDL file declaration.
240 Constant = 34 #: A constant.
242 Generic = 35 #: A VHDL generic.
243 TypeParameter = 36 #: A type parameter, e.g. of a C++ template or a Java generic class.
244 Parameter = 37 #: A parameter declared as an item of its own, e.g. of a VHDL subprogram.
245 Port = 38 #: A VHDL port.
247 Alias = 39 #: An alias, e.g. a VHDL alias declaration.
248 Attribute = 40 #: A VHDL attribute declaration.
249 Other = 41 #: Any other documentable item.
251 DEFAULT = Other #: The kind of an item, which is no other kind.
253 __CATEGORIES: ClassVar[dict[int, ItemCategory]] = {
254 Library: ItemCategory.Container,
255 Package: ItemCategory.Container,
256 PackageBody: ItemCategory.Container,
257 Namespace: ItemCategory.Container,
258 Module: ItemCategory.Container,
259 SourceFile: ItemCategory.Container,
260 Entity: ItemCategory.Container,
261 Architecture: ItemCategory.Container,
262 Configuration: ItemCategory.Container,
263 Context: ItemCategory.Container,
264 Class: ItemCategory.Type,
265 Struct: ItemCategory.Type,
266 Union: ItemCategory.Type,
267 Interface: ItemCategory.Type,
268 Enumeration: ItemCategory.Type,
269 Record: ItemCategory.Type,
270 Type: ItemCategory.Type,
271 TypeAlias: ItemCategory.Type,
272 TypeVariable: ItemCategory.Type,
273 Component: ItemCategory.Type,
274 Function: ItemCategory.Callable,
275 Method: ItemCategory.Callable,
276 Constructor: ItemCategory.Callable,
277 Destructor: ItemCategory.Callable,
278 Property: ItemCategory.Callable,
279 Procedure: ItemCategory.Callable,
280 Macro: ItemCategory.Callable,
281 Process: ItemCategory.Callable,
282 Field: ItemCategory.Field,
283 EnumerationMember: ItemCategory.Field,
284 Variable: ItemCategory.Variable,
285 Signal: ItemCategory.Variable,
286 FileObject: ItemCategory.Variable,
287 Constant: ItemCategory.Constant,
288 Generic: ItemCategory.Generic,
289 TypeParameter: ItemCategory.Generic,
290 Parameter: ItemCategory.Parameter,
291 Port: ItemCategory.Parameter,
292 Alias: ItemCategory.Other,
293 Attribute: ItemCategory.Other,
294 Other: ItemCategory.Other,
295 } #: The category of each kind, by the kind's value.
297 @readonly
298 def Category(self) -> ItemCategory:
299 """
300 Read-only property to return the category this kind belongs to.
302 :returns: The kind's category.
303 """
304 return self.__CATEGORIES[self.value]
307@export
308class CoverageCounters(metaclass=ExtendedType, slots=True):
309 """
310 The numbers of documentable items by their coverage state, and the coverage computed from them.
312 * An item is *expected* to be documented, unless it is *ignored* or *excluded*; an item without anything to document
313 (:attr:`CoverageState.Empty`) isn't counted at all.
314 * An expected item is either *covered* or *uncovered*.
316 .. code-block:: text
318 total = expected + ignored + excluded
319 expected = covered + uncovered
320 coverage = covered / expected
321 """
323 _expected: int #: Number of items expected to be documented.
324 _covered: int #: Number of expected items, which count as documented.
325 _ignored: int #: Number of items, which need no documentation by the reader's rules.
326 _excluded: int #: Number of items excluded from the analysis.
328 def __init__(self) -> None:
329 """
330 Initializes counters of zero items.
331 """
332 self._expected = 0
333 self._covered = 0
334 self._ignored = 0
335 self._excluded = 0
337 @readonly
338 def Total(self) -> int:
339 """
340 Read-only property to return the number of counted items.
342 :returns: The sum of expected, ignored and excluded items.
343 """
344 return self._expected + self._ignored + self._excluded
346 @readonly
347 def Expected(self) -> int:
348 """
349 Read-only property to access the number of items expected to be documented (:attr:`_expected`).
351 :returns: The number of expected items.
352 """
353 return self._expected
355 @readonly
356 def Covered(self) -> int:
357 """
358 Read-only property to access the number of expected items, which count as documented (:attr:`_covered`).
360 :returns: The number of covered items.
361 """
362 return self._covered
364 @readonly
365 def Uncovered(self) -> int:
366 """
367 Read-only property to return the number of expected items, which don't count as documented.
369 :returns: :attr:`Expected` minus :attr:`Covered`.
370 """
371 return self._expected - self._covered
373 @readonly
374 def Ignored(self) -> int:
375 """
376 Read-only property to access the number of items, which need no documentation by the reader's rules
377 (:attr:`_ignored`).
379 :returns: The number of ignored items.
380 """
381 return self._ignored
383 @readonly
384 def Excluded(self) -> int:
385 """
386 Read-only property to access the number of items excluded from the analysis (:attr:`_excluded`).
388 :returns: The number of excluded items.
389 """
390 return self._excluded
392 @readonly
393 def Coverage(self) -> float:
394 """
395 Read-only property to return the documentation coverage: the ratio of covered to expected items.
397 :returns: The coverage in range 0.0..1.0; ``1.0`` if no item is expected.
398 """
399 return 1.0 if self._expected == 0 else self._covered / self._expected
401 def _Count(self, status: CoverageState) -> None:
402 """
403 Count one item of a coverage state.
405 :param status: The item's coverage state.
406 """
407 if CoverageState.Empty in status:
408 return
409 elif CoverageState.Excluded in status:
410 self._excluded += 1
411 elif CoverageState.Ignored in status:
412 self._ignored += 1
413 else:
414 self._expected += 1
415 if CoverageState.Covered in status:
416 self._covered += 1
418 def _Add(self, other: CoverageCounters) -> None:
419 """
420 Add the numbers of other counters to these.
422 :param other: The other counters.
423 """
424 self._expected += other._expected
425 self._covered += other._covered
426 self._ignored += other._ignored
427 self._excluded += other._excluded
429 def __repr__(self) -> str:
430 """
431 Return a representation of the counters for debugging.
433 Counters of 3 covered out of 4 expected items, 1 ignored item and no excluded item read
434 ``<CoverageCounters 3/4, ignored 1, excluded 0: 75.0%>``.
436 :returns: The covered, expected, ignored and excluded items and the coverage.
437 """
438 return (
439 f"<CoverageCounters {self._covered}/{self._expected}, ignored {self._ignored}, excluded {self._excluded}: "
440 f"{self.Coverage:.1%}>"
441 )
444@export
445class Base(metaclass=ExtendedType, slots=True):
446 """
447 Base-class of every element of the documentation coverage model: a reference to the element containing it and to
448 the analysis' root, and the counters computed by :meth:`Aggregate`.
449 """
451 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: The types a parent may have; assigned by each class having a parent.
453 _parent: Nullable[Base] #: The element containing this one, or ``None``.
454 _root: Nullable[DocumentationReport] #: The report, or ``None`` while the element isn't part of one.
455 _counters: CoverageCounters #: The counters of the items this element counts.
456 _categories: dict[ItemCategory, CoverageCounters] #: The counters per category of the items.
458 def __init__(self, *, parent: Nullable[Base] = None) -> None:
459 """
460 Initializes an element with its parent, the parent's root, and counters of zero items.
462 The parent doesn't list the element yet: the class setting the field the parent stores it by - a name - adds it
463 with ``parent._AddElement(self)``.
465 :param parent: Optional, the element containing this one. Default: ``None``.
466 :raises TypeError: If parameter ``parent`` isn't of a type this class declares in :attr:`_PARENT_TYPE`.
467 """
468 if parent is None:
469 self._parent = None
470 self._root = None
471 elif not isinstance(parent, self._PARENT_TYPE):
472 typeNames = " or ".join(f"'{parentType.__name__}'" for parentType in self._PARENT_TYPE)
473 ex = TypeError(f"Parameter 'parent' is not of type {typeNames}.")
474 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
475 raise ex
476 else:
477 self._parent = parent
478 self._root = parent._root
480 self._counters = CoverageCounters()
481 self._categories = {}
483 @property
484 def Parent(self) -> Nullable[Base]:
485 """
486 Property to access the element containing this one (:attr:`_parent`).
488 Assigning a parent adds this element to it, and sets the parent's root as the root of this element and of every
489 element below it.
491 :returns: The parent, or ``None``.
492 :raises ValueError: If ``None`` is assigned.
493 :raises TypeError: If the assigned parent isn't of a type this class declares in
494 :attr:`_PARENT_TYPE`.
495 :raises TypeError: If a parent is assigned to a :class:`DocumentationReport`, which has no
496 parent.
497 :raises AlreadyInDocumentationReportError: If the element already has a parent.
498 :raises DuplicateDirectoryError: If the assigned parent is a directory, which already contains a directory
499 of this name.
500 :raises DuplicateFileError: If the assigned parent is a directory, which already contains a file of
501 this name.
502 """
503 return self._parent
505 @Parent.setter
506 def Parent(self, parent: Base) -> None:
507 if parent is None:
508 raise ValueError("Parameter 'parent' is None.")
509 elif not isinstance(parent, self._PARENT_TYPE):
510 typeNames = " or ".join(f"'{parentType.__name__}'" for parentType in self._PARENT_TYPE)
511 ex = TypeError(f"Parameter 'parent' is not of type {typeNames}.")
512 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
513 raise ex
514 elif self._parent is not None:
515 raise AlreadyInDocumentationReportError(f"{getTypeName(self)} already has a parent.")
517 parent._AddElement(self)
518 self._parent = parent
519 self._SetRoot(parent._root)
521 @readonly
522 def Root(self) -> Nullable[DocumentationReport]:
523 """
524 Read-only property to access the analysis' root (:attr:`_root`).
526 The root is maintained by :attr:`Parent`: assigning a parent sets it for this element and every element below it.
528 :returns: The root, or ``None`` while the element isn't part of an analysis.
529 """
530 return self._root
532 @readonly
533 def Counters(self) -> CoverageCounters:
534 """
535 Read-only property to access the counters of the items this element counts (:attr:`_counters`).
537 They are zero until :meth:`Aggregate` computed them.
539 :returns: The counters.
540 """
541 return self._counters
543 @readonly
544 def Categories(self) -> dict[ItemCategory, CoverageCounters]:
545 """
546 Read-only property to access the counters per category of the items this element counts (:attr:`_categories`).
548 A category without items has no entry. The counters are computed by :meth:`Aggregate`.
550 :returns: The counters, by category.
551 """
552 return self._categories
554 def _ResetCounters(self) -> None:
555 """
556 Set the counters to zero items.
557 """
558 self._counters = CoverageCounters()
559 self._categories = {}
561 def _CountItem(self, item: Item) -> None:
562 """
563 Count an item's own coverage state, in the counters and in the counters of its category.
565 :param item: The item.
566 """
567 self._counters._Count(item._status)
568 category = item._kind.Category
569 if (counters := self._categories.get(category, None)) is None:
570 counters = CoverageCounters()
571 self._categories[category] = counters
573 counters._Count(item._status)
575 def _AddCounters(self, other: Base) -> None:
576 """
577 Add the counters of another element to this one's.
579 :param other: The other element, aggregated.
580 """
581 self._counters._Add(other._counters)
582 for category, otherCounters in other._categories.items():
583 if (counters := self._categories.get(category, None)) is None:
584 counters = CoverageCounters()
585 self._categories[category] = counters
587 counters._Add(otherCounters)
589 def _SetRoot(self, root: Nullable[DocumentationReport]) -> None:
590 """
591 Set the analysis' root of this element.
593 A class containing elements overrides it, to set the root of those too.
595 :param root: The root, or ``None`` while the element isn't part of an analysis.
596 """
597 self._root = root
599 @abstractmethod
600 def Aggregate(self) -> None:
601 """
602 Compute the counters.
603 """
606# A class with a property named like a class - ``Path`` - can't name that class in the annotation of a field: the class
607# body's namespace, where annotations are evaluated, binds the name to the property.
608_Path = Path
611@export
612class BaseWithPath(Base):
613 """
614 Base-class of the physical hierarchy - directories and source files -: a name and a path below the report.
616 Its parent is the :class:`Directory` containing it.
617 """
619 _name: _Path #: Name of the directory or file: a path of one component.
621 def __init__(self, name: Path, *, parent: Nullable[Directory]) -> None:
622 """
623 Initializes the parent, the name and the counters.
625 :param name: Name of the directory or file: a path of one component.
626 :param parent: The directory containing this one, or ``None``.
627 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`.
628 :raises ValueError: If parameter ``name`` is ``None``.
629 :raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
630 :raises ValueError: If parameter ``name`` is empty or ``.``.
631 :raises ValueError: If parameter ``name`` has more than one component.
632 """
633 super().__init__(parent=parent)
635 if name is None:
636 raise ValueError("Parameter 'name' is None.")
637 elif not isinstance(name, Path):
638 ex = TypeError("Parameter 'name' is not of type 'Path'.")
639 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
640 raise ex
641 elif name.name == "":
642 ex = ValueError("Parameter 'name' is empty.")
643 ex.add_note(f"Got path '{name}'.")
644 raise ex
645 elif len(name.parts) > 1:
646 ex = ValueError("Parameter 'name' has more than one component.")
647 ex.add_note(f"Got path '{name.as_posix()}'.")
648 raise ex
650 self._name = name
652 @readonly
653 def Name(self) -> Path:
654 """
655 Read-only property to access the name of the directory or file (:attr:`_name`).
657 :returns: The name: a path of one component.
658 """
659 return self._name
661 @readonly
662 def Path(self) -> Path:
663 """
664 Read-only property to return the path below the report: the names of the parent directories and the own name.
666 A file ``Shapes.py`` in a directory ``myPackage`` has the path ``myPackage/Shapes.py``.
668 :returns: The path; the name, if there is no parent.
669 """
670 if self._parent is None:
671 return self._name
673 return self._parent.Path / self._name
676@export
677class Directory(BaseWithPath):
678 """
679 A directory: its directories and source files, and their summed counters.
680 """
682 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: A directory is in a directory; assigned below the class.
684 _directories: dict[_Path, Directory] #: The directories in this directory, by name.
685 _files: dict[_Path, File] #: The source files in this directory, by name.
687 def __init__(self, name: Path, *, parent: Nullable[Directory] = None) -> None:
688 """
689 Initializes a directory, and adds it to its parent directory.
691 :param name: Name of the directory: a path of one component.
692 :param parent: Optional, the directory containing this one. Default: ``None``.
693 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`.
694 :raises ValueError: If parameter ``name`` is ``None``.
695 :raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
696 :raises ValueError: If parameter ``name`` is empty or ``.``.
697 :raises ValueError: If parameter ``name`` has more than one component.
698 :raises DuplicateDirectoryError: If the parent directory already contains a file or directory of this name.
699 """
700 super().__init__(name, parent=parent)
702 self._directories = {}
703 self._files = {}
705 if parent is not None:
706 parent._AddElement(self)
708 def _AddElement(self, element: Directory | File) -> None:
709 """
710 Add a directory or file, which names this directory as its parent.
712 :param element: The directory or file.
713 :raises DuplicateDirectoryError: If this directory already contains a directory of this name.
714 :raises DuplicateFileError: If this directory already contains a file of this name.
715 """
716 if isinstance(element, Directory):
717 elements = self._directories
718 exception = DuplicateDirectoryError
719 else:
720 elements = self._files
721 exception = DuplicateFileError
723 if element._name in self._directories or element._name in self._files:
724 raise exception(f"Directory '{self.Path.as_posix()}' already contains '{element._name.as_posix()}'.")
726 elements[element._name] = element
728 def _SetRoot(self, root: Nullable[DocumentationReport]) -> None:
729 """
730 Set the analysis' root of this directory, and of the directories and files below it.
732 :param root: The root, or ``None`` while the directory isn't part of an analysis.
733 """
734 self._root = root
735 for directory in self._directories.values():
736 directory._SetRoot(root)
738 for file in self._files.values():
739 file._SetRoot(root)
741 @readonly
742 def Directories(self) -> dict[Path, Directory]:
743 """
744 Read-only property to access the directories in this directory (:attr:`_directories`).
746 :returns: The directories, by name.
747 """
748 return self._directories
750 @readonly
751 def Files(self) -> dict[Path, File]:
752 """
753 Read-only property to access the source files in this directory (:attr:`_files`).
755 :returns: The files, by name.
756 """
757 return self._files
759 @readonly
760 def FileCount(self) -> int:
761 """
762 Read-only property to return the number of source files in this directory and below.
764 :returns: The number of files.
765 """
766 return len(self._files) + sum(directory.FileCount for directory in self._directories.values())
768 def GetOrAddFile(self, path: Path) -> File:
769 """
770 Return the source file at a path below this directory, adding it and the directories on its way, if missing.
772 :param path: The file's path relative to this directory, e.g. ``myPackage/Shapes.py``.
773 :returns: The file.
774 :raises ValueError: If parameter ``path`` is ``None``.
775 :raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path`.
776 :raises ValueError: If parameter ``path`` names no file.
777 :raises DocCoverageError: If a part of the path is a file instead of a directory, or the file's name is a
778 directory's.
779 """
780 if path is None:
781 raise ValueError("Parameter 'path' is None.")
782 elif not isinstance(path, Path):
783 ex = TypeError("Parameter 'path' is not of type 'Path'.")
784 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
785 raise ex
787 parts = [Path(part) for part in path.parts if part not in ("", ".", path.anchor)]
788 if len(parts) == 0:
789 ex = ValueError("Parameter 'path' names no file.")
790 ex.add_note(f"Got path '{path}'.")
791 raise ex
793 directory = self
794 for part in parts[:-1]:
795 if part in directory._files:
796 raise DocCoverageError(f"'{(directory.Path / part).as_posix()}' is a file, not a directory.")
797 elif (subdirectory := directory._directories.get(part)) is None:
798 subdirectory = Directory(part, parent=directory)
799 directory = subdirectory
801 if (file := directory._files.get(parts[-1])) is None:
802 file = File(parts[-1], parent=directory)
804 return file
806 def IterateFiles(self) -> Generator[File, None, None]:
807 """
808 Iterate the source files in this directory and below.
810 A directory's files come before its directories' files, each in the order they were added.
812 :returns: A generator of the files.
813 """
814 yield from self._files.values()
815 for directory in self._directories.values():
816 yield from directory.IterateFiles()
818 def Aggregate(self) -> None:
819 """
820 Aggregate the directories and files in this directory, then sum their counters.
821 """
822 self._ResetCounters()
823 for child in (*self._directories.values(), *self._files.values()):
824 child.Aggregate()
825 self._AddCounters(child)
827 def __repr__(self) -> str:
828 """
829 Return a representation of the directory for debugging.
831 A directory ``myPackage`` of 3 files reads ``<Directory myPackage: 3 files, 75.0%>``.
833 :returns: The directory's path, its number of files and its documentation coverage.
834 """
835 return f"<Directory {self.Path.as_posix()}: {self.FileCount} files, {self._counters.Coverage:.1%}>"
838Directory._PARENT_TYPE = (Directory, )
841@export
842class DocumentationReport(Directory):
843 """
844 The report of a documentation coverage analysis: the directory the source files' paths are relative to, and the
845 top-level items of the logical hierarchy.
847 It is its own root. Its counters count every item of the logical hierarchy; its directories and files count only
848 the items declared in a source file.
849 """
851 _items: list[Item] #: The top-level items, in the order they were added.
852 _analysisDuration: Nullable[timedelta] #: Duration of the analysis, which produced the report, if measured.
854 def __init__(self, name: Path) -> None:
855 """
856 Initializes the report of a documentation coverage analysis.
858 :param name: Name of the report: a path of one component, e.g. the analyzed package's name.
859 :raises ValueError: If parameter ``name`` is ``None``.
860 :raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
861 :raises ValueError: If parameter ``name`` is empty or ``.``.
862 :raises ValueError: If parameter ``name`` has more than one component.
863 """
864 super().__init__(name)
866 self._root = self
867 self._items = []
868 self._analysisDuration = None
870 @Base.Parent.setter
871 def Parent(self, parent: None) -> None:
872 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.")
873 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
874 raise ex
876 def _AddElement(self, element: Directory | File | Item) -> None:
877 """
878 Add a top-level item, a directory or a file, which names the report as its parent.
880 :param element: The item, directory or file.
881 :raises DuplicateDirectoryError: If the report already contains a directory of this name.
882 :raises DuplicateFileError: If the report already contains a file of this name.
883 """
884 if isinstance(element, BaseWithPath):
885 super()._AddElement(element)
886 else:
887 self._items.append(element)
889 @readonly
890 def Path(self) -> Path:
891 """
892 Read-only property to return the path below the report, which is the report's own: ``.``.
894 So the paths of the directories and files below are relative to the report, without its name.
896 :returns: ``.``
897 """
898 return Path(".")
900 @readonly
901 def Items(self) -> list[Item]:
902 """
903 Read-only property to access the top-level items of the logical hierarchy (:attr:`_items`).
905 :returns: The items, in the order they were added.
906 """
907 return self._items
909 @readonly
910 def AnalysisDuration(self) -> Nullable[timedelta]:
911 """
912 Read-only property to access the duration of the analysis, which produced the report (:attr:`_analysisDuration`).
914 A reader measures it; a report built otherwise has none.
916 :returns: The duration, or ``None`` if it wasn't measured.
917 """
918 return self._analysisDuration
920 def Aggregate(self) -> None:
921 """
922 Aggregate the physical hierarchy - directories and files -, then the items of the logical hierarchy, which the
923 report's counters sum.
924 """
925 super().Aggregate()
927 self._ResetCounters()
928 for item in self._items:
929 item.Aggregate()
930 self._AddCounters(item)
933@export
934class File(BaseWithPath):
935 """
936 A source file: the items declared in it, and their counters.
938 The items are those of the logical hierarchy naming this file; the file counts each item's own state, not the items
939 it contains, which name their file themselves.
940 """
942 _PARENT_TYPE: ClassVar[tuple[type, ...]] = (Directory, ) #: A file is in a directory.
944 _items: list[Item] #: The items declared in this file, in the order they were added.
946 def __init__(self, name: Path, *, parent: Nullable[Directory] = None) -> None:
947 """
948 Initializes a source file, and adds it to its directory.
950 :param name: Name of the file: a path of one component.
951 :param parent: Optional, the directory containing the file. Default: ``None``.
952 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`.
953 :raises ValueError: If parameter ``name`` is ``None``.
954 :raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
955 :raises ValueError: If parameter ``name`` is empty or ``.``.
956 :raises ValueError: If parameter ``name`` has more than one component.
957 :raises DuplicateFileError: If the directory already contains a file or directory of this name.
958 """
959 super().__init__(name, parent=parent)
961 self._items = []
963 if parent is not None:
964 parent._AddElement(self)
966 @readonly
967 def Items(self) -> list[Item]:
968 """
969 Read-only property to access the items declared in this file (:attr:`_items`).
971 :returns: The items, in the order they were added.
972 """
973 return self._items
975 def Aggregate(self) -> None:
976 """
977 Count the own coverage state of each item declared in this file.
978 """
979 self._ResetCounters()
980 for item in self._items:
981 self._CountItem(item)
983 def __repr__(self) -> str:
984 """
985 Return a representation of the file for debugging.
987 A file ``Shapes.py`` of 4 items reads ``<File myPackage/Shapes.py: 4 items, 75.0%>``.
989 :returns: The file's path, its number of items and its documentation coverage.
990 """
991 return f"<File {self.Path.as_posix()}: {len(self._items)} items, {self._counters.Coverage:.1%}>"
994# A class with a property named like a class - ``File`` - can't name that class in the annotation of a field: the class
995# body's namespace, where annotations are evaluated, binds the name to the property.
996_File = File
999@export
1000class Item(Base):
1001 """
1002 A documentable item of a language, its coverage state, and the items it contains.
1004 Its parent is the :class:`Item` containing it, or the :class:`DocumentationReport` for a top-level item. Items of one
1005 parent may share a name, e.g. a property's getter and setter.
1007 A container's own documentation - a module's or a class' doc-string - is the container's own coverage state; the
1008 items it contains have states of their own. So an item's counters count the item itself and every item below it.
1009 """
1011 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: An item is in an item or the report; assigned below the class.
1013 _name: str #: Name of the item.
1014 _kind: ItemKind #: The language construct the item is.
1015 _status: CoverageState #: The item's own coverage state.
1016 _file: Nullable[_File] #: The source file the item is declared in, if the reader knows it.
1017 _startLine: Nullable[int] #: The item's first line, if the reader knows it.
1018 _endLine: Nullable[int] #: The item's last line, if the reader knows it.
1019 _items: list[Item] #: The items this one contains, in the order they were added.
1021 def __init__(
1022 self,
1023 name: str,
1024 kind: ItemKind,
1025 status: CoverageState,
1026 *,
1027 file: Nullable[File] = None,
1028 startLine: Nullable[int] = None,
1029 endLine: Nullable[int] = None,
1030 parent: Nullable[Item | DocumentationReport] = None
1031 ) -> None:
1032 """
1033 Initializes an item, and adds it to its file and to the item or report containing it.
1035 :param name: Name of the item.
1036 :param kind: The language construct the item is.
1037 :param status: The item's own coverage state.
1038 :param file: Optional, the source file the item is declared in. Default: ``None``.
1039 :param startLine: Optional, the item's first line. Default: ``None``.
1040 :param endLine: Optional, the item's last line. Default: ``None``.
1041 :param parent: Optional, the item or report containing this item. Default: ``None``.
1042 :raises TypeError: If parameter ``parent`` isn't of type :class:`Item` or :class:`DocumentationReport`.
1043 :raises ValueError: If parameter ``name`` is ``None``.
1044 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
1045 :raises ValueError: If parameter ``name`` is empty.
1046 :raises ValueError: If parameter ``kind`` is ``None``.
1047 :raises TypeError: If parameter ``kind`` isn't of type :class:`ItemKind`.
1048 :raises ValueError: If parameter ``status`` is ``None``.
1049 :raises TypeError: If parameter ``status`` isn't of type :class:`CoverageState`.
1050 :raises ValueError: If parameter ``status`` is ``CoverageState.Unknown``.
1051 :raises TypeError: If parameter ``file`` isn't of type :class:`File`.
1052 :raises TypeError: If parameter ``startLine`` isn't of type :class:`int`.
1053 :raises ValueError: If parameter ``startLine`` is less than 1.
1054 :raises TypeError: If parameter ``endLine`` isn't of type :class:`int`.
1055 :raises ValueError: If parameter ``endLine`` is before parameter ``startLine``, or less than 1.
1056 """
1057 super().__init__(parent=parent)
1059 if name is None:
1060 raise ValueError("Parameter 'name' is None.")
1061 elif not isinstance(name, str):
1062 ex = TypeError("Parameter 'name' is not of type 'str'.")
1063 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
1064 raise ex
1065 elif name == "":
1066 raise ValueError("Parameter 'name' is empty.")
1068 if kind is None:
1069 raise ValueError("Parameter 'kind' is None.")
1070 elif not isinstance(kind, ItemKind):
1071 ex = TypeError("Parameter 'kind' is not of type 'ItemKind'.")
1072 ex.add_note(f"Got type '{getFullyQualifiedName(kind)}'.")
1073 raise ex
1075 if status is None:
1076 raise ValueError("Parameter 'status' is None.")
1077 elif not isinstance(status, CoverageState):
1078 ex = TypeError("Parameter 'status' is not of type 'CoverageState'.")
1079 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.")
1080 raise ex
1081 elif status is CoverageState.Unknown:
1082 raise ValueError("Parameter 'status' is 'Unknown'.")
1084 if file is not None and not isinstance(file, File):
1085 ex = TypeError("Parameter 'file' is not of type 'File'.")
1086 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.")
1087 raise ex
1089 if startLine is not None:
1090 if not isinstance(startLine, int):
1091 ex = TypeError("Parameter 'startLine' is not of type 'int'.")
1092 ex.add_note(f"Got type '{getFullyQualifiedName(startLine)}'.")
1093 raise ex
1094 elif startLine < 1:
1095 ex = ValueError("Parameter 'startLine' is less than 1.")
1096 ex.add_note(f"Got value '{startLine}'.")
1097 raise ex
1099 if endLine is not None:
1100 if not isinstance(endLine, int):
1101 ex = TypeError("Parameter 'endLine' is not of type 'int'.")
1102 ex.add_note(f"Got type '{getFullyQualifiedName(endLine)}'.")
1103 raise ex
1104 elif endLine < (1 if startLine is None else startLine):
1105 ex = ValueError("Parameter 'endLine' is before parameter 'startLine', or less than 1.")
1106 ex.add_note(f"Got lines {startLine} to {endLine}.")
1107 raise ex
1109 self._name = name
1110 self._kind = kind
1111 self._status = status
1112 self._file = file
1113 self._startLine = startLine
1114 self._endLine = endLine
1115 self._items = []
1117 if parent is not None:
1118 parent._AddElement(self)
1120 if file is not None:
1121 file._items.append(self)
1123 def _AddElement(self, item: Item) -> None:
1124 """
1125 Add an item, which names this item as its parent.
1127 :param item: The item.
1128 """
1129 self._items.append(item)
1131 def _SetRoot(self, root: Nullable[DocumentationReport]) -> None:
1132 """
1133 Set the analysis' root of this item, and of the items below it.
1135 :param root: The root, or ``None`` while the item isn't part of an analysis.
1136 """
1137 self._root = root
1138 for item in self._items:
1139 item._SetRoot(root)
1141 def IterateDescendants(self, predicate: Nullable[Callable[[Item], bool]] = None) -> Generator[Item, None, None]:
1142 """
1143 Iterate all or selected items below this one - the items it contains, theirs, ... -, depth-first: each item
1144 followed by the items below it.
1146 If parameter ``predicate`` is not None, the given filter function is used to skip items in the generator. A skipped
1147 item's items are still iterated.
1149 :param predicate: Optional, filter function accepting an item and returning a boolean. Default: ``None``.
1150 :returns: A generator of the items below this one.
1151 """
1152 if predicate is None:
1153 for item in self._items:
1154 yield item
1155 yield from item.IterateDescendants()
1156 else:
1157 for item in self._items:
1158 if predicate(item):
1159 yield item
1161 yield from item.IterateDescendants(predicate)
1163 @readonly
1164 def Name(self) -> str:
1165 """
1166 Read-only property to access the item's name (:attr:`_name`).
1168 :returns: The name.
1169 """
1170 return self._name
1172 @readonly
1173 def Kind(self) -> ItemKind:
1174 """
1175 Read-only property to access the language construct the item is (:attr:`_kind`).
1177 :returns: The item's kind.
1178 """
1179 return self._kind
1181 @readonly
1182 def Category(self) -> ItemCategory:
1183 """
1184 Read-only property to access the category of the item's kind (:attr:`_kind`).
1186 :returns: The item's category.
1187 """
1188 return self._kind.Category
1190 @readonly
1191 def Status(self) -> CoverageState:
1192 """
1193 Read-only property to access the item's own coverage state (:attr:`_status`).
1195 :returns: The coverage state.
1196 """
1197 return self._status
1199 @readonly
1200 def File(self) -> Nullable[File]:
1201 """
1202 Read-only property to access the source file the item is declared in (:attr:`_file`).
1204 :returns: The file, or ``None`` if the reader doesn't know it.
1205 """
1206 return self._file
1208 @readonly
1209 def StartLine(self) -> Nullable[int]:
1210 """
1211 Read-only property to access the item's first line (:attr:`_startLine`).
1213 :returns: The line number, or ``None`` if the reader doesn't know it.
1214 """
1215 return self._startLine
1217 @readonly
1218 def EndLine(self) -> Nullable[int]:
1219 """
1220 Read-only property to access the item's last line (:attr:`_endLine`).
1222 :returns: The line number, or ``None`` if the reader doesn't know it.
1223 """
1224 return self._endLine
1226 @readonly
1227 def Items(self) -> list[Item]:
1228 """
1229 Read-only property to access the items this one contains (:attr:`_items`).
1231 :returns: The items, in the order they were added.
1232 """
1233 return self._items
1235 @readonly
1236 def QualifiedName(self) -> str:
1237 """
1238 Read-only property to return the names of the items from the top down to this one, joined by ``.``.
1240 A method ``Area`` of a class ``Circle`` in a module ``myPackage.Shapes`` has the qualified name
1241 ``myPackage.Shapes.Circle.Area``.
1243 :returns: The qualified name.
1244 """
1245 if isinstance(self._parent, Item):
1246 return f"{self._parent.QualifiedName}.{self._name}"
1248 return self._name
1250 def __len__(self) -> int:
1251 """
1252 Return the number of items this one contains.
1254 :returns: The number of items.
1255 """
1256 return len(self._items)
1258 def __iter__(self) -> Iterator[Item]:
1259 """
1260 Iterate the items this one contains.
1262 :returns: An iterator of the items, in the order they were added.
1263 """
1264 return iter(self._items)
1266 def __contains__(self, name: str) -> bool:
1267 """
1268 Return whether this item contains an item of a name.
1270 :param name: The name.
1271 :returns: ``True``, if an item of this name is contained.
1272 """
1273 return any(item._name == name for item in self._items)
1275 def __getitem__(self, name: str) -> Item:
1276 """
1277 Return the contained item of a name; the first one, if several share it.
1279 A property's getter is added before its setter, so it is the one returned.
1281 :param name: The name.
1282 :returns: The item.
1283 :raises KeyError: If no item of this name is contained.
1284 """
1285 for item in self._items:
1286 if item._name == name:
1287 return item
1289 raise KeyError(f"Item '{self.QualifiedName}' contains no item '{name}'.")
1291 def Aggregate(self) -> None:
1292 """
1293 Aggregate the items this one contains, then count this item's own coverage state and add their counters.
1294 """
1295 self._ResetCounters()
1296 self._CountItem(self)
1297 for item in self._items:
1298 item.Aggregate()
1299 self._AddCounters(item)
1301 def __repr__(self) -> str:
1302 """
1303 Return a representation of the item for debugging.
1305 A method ``Area`` of class ``Circle`` in module ``Shapes`` reads ``<Item Method Shapes.Circle.Area: 100.0%>``.
1307 :returns: The item's kind, qualified name and documentation coverage.
1308 """
1309 return f"<Item {self._kind.name} {self.QualifiedName}: {self._counters.Coverage:.1%}>"
1312Item._PARENT_TYPE = (Item, DocumentationReport)