Coverage for pyEDAA/Reports/DocumentationCoverage/__init__.py: 100%
503 statements
« prev ^ index » next coverage.py v7.17.3, created at 2026-11-14 02:26 +0000
« prev ^ index » next coverage.py v7.17.3, created at 2026-11-14 02:26 +0000
1# ==================================================================================================================== #
2# _____ ____ _ _ ____ _ #
3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
7# |_| |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 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"""
57from __future__ import annotations
59from datetime import timedelta
60from enum import Enum, Flag
61from pathlib import Path
62from typing import Callable, ClassVar, Generator, Iterator, Optional as Nullable
64from pyTooling.Common import getFullyQualifiedName, getTypeName
65from pyTooling.Decorators import export, readonly
66from pyTooling.Exceptions import AlreadyInHierarchyError, DuplicateElementError
67from pyTooling.MetaClasses import ExtendedType, abstractmethod
68from pyTooling.Warning import CriticalWarning
70from pyEDAA.Reports import ReportException
73@export
74class DocCoverageError(ReportException):
75 """Base-exception of the documentation coverage data model and its readers."""
78@export
79class AlreadyInDocumentationReportError(DocCoverageError, AlreadyInHierarchyError):
80 """The exception is raised when an element is added to an element, but it already has a parent."""
83@export
84class DuplicateDirectoryError(DocCoverageError, DuplicateElementError):
85 """The exception is raised when a directory already contains a directory or a file of the same name."""
88@export
89class DuplicateFileError(DocCoverageError, DuplicateElementError):
90 """The exception is raised when a directory or a report already contains a file or a directory of the same name."""
93@export
94class SourceFileWarning(CriticalWarning):
95 """
96 A source file can't be analyzed: it can't be read, decoded or parsed.
98 The reader excludes the file's item from the analysis, and continues with the next file. The cause is the exception
99 reading, decoding or parsing raised.
101 .. important::
103 This warning is critical: it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
104 call-hierarchy, otherwise :meth:`~pyTooling.Warning.WarningCollector.Raise` raises an
105 :exc:`~pyTooling.Warning.UnhandledCriticalWarningError`.
106 """
108 _file: Nullable[_File] #: The report's file of the source file.
110 def __init__(self, message: str, /, *, file: Nullable[_File] = None) -> None:
111 """
112 Initializes the warning with the report's file of the source file that can't be analyzed.
114 :param message: The warning's message.
115 :param file: Optional, the report's file of the source file.
116 """
117 super().__init__(message)
118 self._file = file
120 @readonly
121 def File(self) -> Nullable[_File]:
122 """
123 Read-only property to access the report's file of the source file that can't be analyzed (:attr:`_file`).
125 Its path, relative to the analyzed directory, is
126 :attr:`File.Path <pyEDAA.Reports.DocumentationCoverage.File.Path>`.
128 :returns: The file, or ``None`` if it wasn't recorded.
129 """
130 return self._file
133@export
134class CoverageState(Flag):
135 """
136 The documentation state of an item, as flags.
138 :class:`CoverageCounters` counts an item by the first of these flags it has:
140 * :attr:`Empty`: not counted.
141 * :attr:`Excluded`: counted as excluded.
142 * :attr:`Ignored`: counted as ignored.
143 * :attr:`Covered`: counted as expected and covered.
144 * None of them: counted as expected and uncovered.
145 """
147 Unknown = 0 #: No state; an item can't have it.
148 Excluded = 1 #: Excluded from the analysis, e.g. by a name pattern.
149 Ignored = 2 #: Needs no documentation by the reader's rules, e.g. a property's setter.
150 Empty = 4 #: Has nothing to document, e.g. an empty module; not counted.
151 Covered = 8 #: Counts as documented.
153 Weak = 16 #: Documented, but too briefly.
154 Incomplete = 32 #: Documented, but not every part of it.
155 Inherited = 64 #: Undocumented, but takes the documentation of what it overrides, as a comment states.
156 Detailed = 128 #: Documented in detail.
158 Undocumented = 256 #: Has no documentation.
159 Documented = 512 #: Has documentation.
161 Parameters = 1024 #: Its parameters are documented.
162 ReturnValue = 2048 #: Its return value is documented.
163 Exceptions = 8192 #: The exceptions it raises are documented.
164 Types = 16384 #: Its types are documented.
166 Excused = 32768 #: Undocumented, but a comment excuses the missing documentation.
169@export
170class ItemCategory(Enum):
171 """
172 The category of a documentable item, shared by all languages.
174 Each :class:`ItemKind` belongs to one category (:attr:`ItemKind.Category`), so the counters of every language break
175 down the same way.
176 """
178 Container = 1 #: Contains other items, e.g. a package, a module, a namespace or a VHDL entity.
179 Type = 2 #: Declares a type, e.g. a class, a struct, an enumeration or a type alias.
180 Callable = 3 #: Can be called, e.g. a function, a method, a procedure or a property.
181 Field = 4 #: Data of a type, e.g. a class' field, a record's element or an enumeration's member.
182 Variable = 5 #: Data of a container, whose value can change, e.g. a module's variable or a VHDL signal.
183 Constant = 6 #: Data of a container, whose value can't change, e.g. a constant of C or VHDL.
184 Generic = 7 #: A compile-time parameter, e.g. a VHDL generic or a C++ template parameter.
185 Parameter = 8 #: A run-time parameter declared as an item of its own, e.g. a VHDL port.
186 Other = 9 #: Belongs to no other category.
188 DEFAULT = Other #: The category of an item, which belongs to no other category.
191@export
192class ItemKind(Enum):
193 """
194 The kind of a documentable item: the language construct it is.
196 Languages share a kind, where they share the construct, e.g. a package of Python, Java and Go. A construct only one
197 language has, has a kind of its own, e.g. a VHDL entity. Each kind belongs to an :class:`ItemCategory`
198 (:attr:`Category`).
199 """
201 Library = 1 #: A library of design units, e.g. a VHDL library.
202 Package = 2 #: A package, e.g. of Python, Java or Go, or a VHDL package.
203 PackageBody = 3 #: The body of a VHDL package.
204 Namespace = 4 #: A namespace, e.g. of C++ or TCL.
205 Module = 5 #: A module, e.g. a Python module.
206 SourceFile = 6 #: A source file, where the file is the language's unit, e.g. in C.
207 Entity = 7 #: A VHDL entity.
208 Architecture = 8 #: A VHDL architecture.
209 Configuration = 9 #: A VHDL configuration.
210 Context = 10 #: A VHDL context.
212 Class = 11 #: A class.
213 Struct = 12 #: A structure, e.g. of C, C++ or Go.
214 Union = 13 #: A union of C or C++.
215 Interface = 14 #: An interface, e.g. of Java or Go.
216 Enumeration = 15 #: An enumeration.
217 Record = 16 #: A record, e.g. of Java or a VHDL record type.
218 Type = 17 #: Any other type declaration, e.g. a VHDL type or subtype.
219 TypeAlias = 18 #: A type alias, e.g. a C ``typedef`` or Python's ``type`` statement.
220 TypeVariable = 19 #: A type variable, e.g. Python's ``TypeVar``.
221 Component = 20 #: A VHDL component declaration.
223 Function = 21 #: A function.
224 Method = 22 #: A method of a type.
225 Constructor = 23 #: A type's constructor, e.g. Python's ``__init__``.
226 Destructor = 24 #: A type's destructor, e.g. of C++, or Python's ``__del__``.
227 Property = 25 #: A property: a method accessed like a field.
228 Procedure = 26 #: A procedure, e.g. of VHDL or TCL.
229 Macro = 27 #: A preprocessor macro, e.g. of C.
230 Process = 28 #: A VHDL process.
232 Field = 29 #: A field of a type, e.g. a class' field or a record's element.
233 EnumerationMember = 30 #: A member of an enumeration.
235 Variable = 31 #: A variable of a container, e.g. a module's variable.
236 Signal = 32 #: A VHDL signal.
237 FileObject = 33 #: A file object, e.g. declared by a VHDL file declaration.
238 Constant = 34 #: A constant.
240 Generic = 35 #: A VHDL generic.
241 TypeParameter = 36 #: A type parameter, e.g. of a C++ template or a Java generic class.
242 Parameter = 37 #: A parameter declared as an item of its own, e.g. of a VHDL subprogram.
243 Port = 38 #: A VHDL port.
245 Alias = 39 #: An alias, e.g. a VHDL alias declaration.
246 Attribute = 40 #: A VHDL attribute declaration.
247 Other = 41 #: Any other documentable item.
249 DEFAULT = Other #: The kind of an item, which is no other kind.
251 __CATEGORIES: ClassVar[dict[int, ItemCategory]] = {
252 Library: ItemCategory.Container,
253 Package: ItemCategory.Container,
254 PackageBody: ItemCategory.Container,
255 Namespace: ItemCategory.Container,
256 Module: ItemCategory.Container,
257 SourceFile: ItemCategory.Container,
258 Entity: ItemCategory.Container,
259 Architecture: ItemCategory.Container,
260 Configuration: ItemCategory.Container,
261 Context: ItemCategory.Container,
262 Class: ItemCategory.Type,
263 Struct: ItemCategory.Type,
264 Union: ItemCategory.Type,
265 Interface: ItemCategory.Type,
266 Enumeration: ItemCategory.Type,
267 Record: ItemCategory.Type,
268 Type: ItemCategory.Type,
269 TypeAlias: ItemCategory.Type,
270 TypeVariable: ItemCategory.Type,
271 Component: ItemCategory.Type,
272 Function: ItemCategory.Callable,
273 Method: ItemCategory.Callable,
274 Constructor: ItemCategory.Callable,
275 Destructor: ItemCategory.Callable,
276 Property: ItemCategory.Callable,
277 Procedure: ItemCategory.Callable,
278 Macro: ItemCategory.Callable,
279 Process: ItemCategory.Callable,
280 Field: ItemCategory.Field,
281 EnumerationMember: ItemCategory.Field,
282 Variable: ItemCategory.Variable,
283 Signal: ItemCategory.Variable,
284 FileObject: ItemCategory.Variable,
285 Constant: ItemCategory.Constant,
286 Generic: ItemCategory.Generic,
287 TypeParameter: ItemCategory.Generic,
288 Parameter: ItemCategory.Parameter,
289 Port: ItemCategory.Parameter,
290 Alias: ItemCategory.Other,
291 Attribute: ItemCategory.Other,
292 Other: ItemCategory.Other,
293 } #: The category of each kind, by the kind's value.
295 @readonly
296 def Category(self) -> ItemCategory:
297 """
298 Read-only property to return the category this kind belongs to.
300 :returns: The kind's category.
301 """
302 return self.__CATEGORIES[self.value]
305@export
306class CoverageCounters(metaclass=ExtendedType, slots=True):
307 """
308 The numbers of documentable items by their coverage state, and the coverage computed from them.
310 * An item is *expected* to be documented, unless it is *ignored* or *excluded*; an item without anything to document
311 (:attr:`CoverageState.Empty`) isn't counted at all.
312 * An expected item is either *covered* or *uncovered*.
314 .. code-block:: text
316 total = expected + ignored + excluded
317 expected = covered + uncovered
318 coverage = covered / expected
319 """
321 _expected: int #: Number of items expected to be documented.
322 _covered: int #: Number of expected items, which count as documented.
323 _ignored: int #: Number of items, which need no documentation by the reader's rules.
324 _excluded: int #: Number of items excluded from the analysis.
326 def __init__(self) -> None:
327 """
328 Initializes counters of zero items.
329 """
330 self._expected = 0
331 self._covered = 0
332 self._ignored = 0
333 self._excluded = 0
335 @readonly
336 def Total(self) -> int:
337 """
338 Read-only property to return the number of counted items.
340 :returns: The sum of expected, ignored and excluded items.
341 """
342 return self._expected + self._ignored + self._excluded
344 @readonly
345 def Expected(self) -> int:
346 """
347 Read-only property to access the number of items expected to be documented (:attr:`_expected`).
349 :returns: The number of expected items.
350 """
351 return self._expected
353 @readonly
354 def Covered(self) -> int:
355 """
356 Read-only property to access the number of expected items, which count as documented (:attr:`_covered`).
358 :returns: The number of covered items.
359 """
360 return self._covered
362 @readonly
363 def Uncovered(self) -> int:
364 """
365 Read-only property to return the number of expected items, which don't count as documented.
367 :returns: :attr:`Expected` minus :attr:`Covered`.
368 """
369 return self._expected - self._covered
371 @readonly
372 def Ignored(self) -> int:
373 """
374 Read-only property to access the number of items, which need no documentation by the reader's rules
375 (:attr:`_ignored`).
377 :returns: The number of ignored items.
378 """
379 return self._ignored
381 @readonly
382 def Excluded(self) -> int:
383 """
384 Read-only property to access the number of items excluded from the analysis (:attr:`_excluded`).
386 :returns: The number of excluded items.
387 """
388 return self._excluded
390 @readonly
391 def Coverage(self) -> float:
392 """
393 Read-only property to return the documentation coverage: the ratio of covered to expected items.
395 :returns: The coverage in range 0.0..1.0; ``1.0`` if no item is expected.
396 """
397 return 1.0 if self._expected == 0 else self._covered / self._expected
399 def _Count(self, status: CoverageState) -> None:
400 """
401 Count one item of a coverage state.
403 :param status: The item's coverage state.
404 """
405 if CoverageState.Empty in status:
406 return
407 elif CoverageState.Excluded in status:
408 self._excluded += 1
409 elif CoverageState.Ignored in status:
410 self._ignored += 1
411 else:
412 self._expected += 1
413 if CoverageState.Covered in status:
414 self._covered += 1
416 def _Add(self, other: CoverageCounters) -> None:
417 """
418 Add the numbers of other counters to these.
420 :param other: The other counters.
421 """
422 self._expected += other._expected
423 self._covered += other._covered
424 self._ignored += other._ignored
425 self._excluded += other._excluded
427 def __repr__(self) -> str:
428 """
429 Return a representation of the counters for debugging.
431 Counters of 3 covered out of 4 expected items, 1 ignored item and no excluded item read
432 ``<CoverageCounters 3/4, ignored 1, excluded 0: 75.0%>``.
434 :returns: The covered, expected, ignored and excluded items and the coverage.
435 """
436 return (
437 f"<CoverageCounters {self._covered}/{self._expected}, ignored {self._ignored}, excluded {self._excluded}: "
438 f"{self.Coverage:.1%}>"
439 )
442@export
443class Base(metaclass=ExtendedType, slots=True):
444 """
445 Base-class of every element of the documentation coverage model: a reference to the element containing it and to
446 the analysis' root, and the counters computed by :meth:`Aggregate`.
447 """
449 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: The types a parent may have; assigned by each class having a parent.
451 _parent: Nullable[Base] #: The element containing this one, or ``None``.
452 _root: Nullable[DocumentationReport] #: The report, or ``None`` while the element isn't part of one.
453 _counters: CoverageCounters #: The counters of the items this element counts.
454 _categories: dict[ItemCategory, CoverageCounters] #: The counters per category of the items.
456 def __init__(self, *, parent: Nullable[Base] = None) -> None:
457 """
458 Initializes an element with its parent, the parent's root, and counters of zero items.
460 The parent doesn't list the element yet: the class setting the field the parent stores it by - a name - adds it
461 with ``parent._AddElement(self)``.
463 :param parent: Optional, the element containing this one. Default: ``None``.
464 :raises TypeError: If parameter ``parent`` isn't of a type this class declares in :attr:`_PARENT_TYPE`.
465 """
466 if parent is None:
467 self._parent = None
468 self._root = None
469 elif not isinstance(parent, self._PARENT_TYPE):
470 typeNames = " or ".join(f"'{parentType.__name__}'" for parentType in self._PARENT_TYPE)
471 ex = TypeError(f"Parameter 'parent' is not of type {typeNames}.")
472 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
473 raise ex
474 else:
475 self._parent = parent
476 self._root = parent._root
478 self._counters = CoverageCounters()
479 self._categories = {}
481 @property
482 def Parent(self) -> Nullable[Base]:
483 """
484 Property to access the element containing this one (:attr:`_parent`).
486 Assigning a parent adds this element to it, and sets the parent's root as the root of this element and of every
487 element below it.
489 :returns: The parent, or ``None``.
490 :raises ValueError: If ``None`` is assigned.
491 :raises TypeError: If the assigned parent isn't of a type this class declares in
492 :attr:`_PARENT_TYPE`.
493 :raises TypeError: If a parent is assigned to a :class:`DocumentationReport`, which has no
494 parent.
495 :raises AlreadyInDocumentationReportError: If the element already has a parent.
496 :raises DuplicateDirectoryError: If the assigned parent is a directory, which already contains a directory
497 of this name.
498 :raises DuplicateFileError: If the assigned parent is a directory, which already contains a file of
499 this name.
500 """
501 return self._parent
503 @Parent.setter
504 def Parent(self, parent: Base) -> None:
505 if parent is None:
506 raise ValueError("Parameter 'parent' is None.")
507 elif not isinstance(parent, self._PARENT_TYPE):
508 typeNames = " or ".join(f"'{parentType.__name__}'" for parentType in self._PARENT_TYPE)
509 ex = TypeError(f"Parameter 'parent' is not of type {typeNames}.")
510 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
511 raise ex
512 elif self._parent is not None:
513 raise AlreadyInDocumentationReportError(f"{getTypeName(self)} already has a parent.")
515 parent._AddElement(self)
516 self._parent = parent
517 self._SetRoot(parent._root)
519 @readonly
520 def Root(self) -> Nullable[DocumentationReport]:
521 """
522 Read-only property to access the analysis' root (:attr:`_root`).
524 The root is maintained by :attr:`Parent`: assigning a parent sets it for this element and every element below it.
526 :returns: The root, or ``None`` while the element isn't part of an analysis.
527 """
528 return self._root
530 @readonly
531 def Counters(self) -> CoverageCounters:
532 """
533 Read-only property to access the counters of the items this element counts (:attr:`_counters`).
535 They are zero until :meth:`Aggregate` computed them.
537 :returns: The counters.
538 """
539 return self._counters
541 @readonly
542 def Categories(self) -> dict[ItemCategory, CoverageCounters]:
543 """
544 Read-only property to access the counters per category of the items this element counts (:attr:`_categories`).
546 A category without items has no entry. The counters are computed by :meth:`Aggregate`.
548 :returns: The counters, by category.
549 """
550 return self._categories
552 def _ResetCounters(self) -> None:
553 """
554 Set the counters to zero items.
555 """
556 self._counters = CoverageCounters()
557 self._categories = {}
559 def _CountItem(self, item: Item) -> None:
560 """
561 Count an item's own coverage state, in the counters and in the counters of its category.
563 :param item: The item.
564 """
565 self._counters._Count(item._status)
566 category = item._kind.Category
567 if (counters := self._categories.get(category, None)) is None:
568 counters = CoverageCounters()
569 self._categories[category] = counters
571 counters._Count(item._status)
573 def _AddCounters(self, other: Base) -> None:
574 """
575 Add the counters of another element to this one's.
577 :param other: The other element, aggregated.
578 """
579 self._counters._Add(other._counters)
580 for category, otherCounters in other._categories.items():
581 if (counters := self._categories.get(category, None)) is None:
582 counters = CoverageCounters()
583 self._categories[category] = counters
585 counters._Add(otherCounters)
587 def _SetRoot(self, root: Nullable[DocumentationReport]) -> None:
588 """
589 Set the analysis' root of this element.
591 A class containing elements overrides it, to set the root of those too.
593 :param root: The root, or ``None`` while the element isn't part of an analysis.
594 """
595 self._root = root
597 @abstractmethod
598 def Aggregate(self) -> None:
599 """
600 Compute the counters.
601 """
604# A class with a property named like a class - ``Path`` - can't name that class in the annotation of a field: the class
605# body's namespace, where annotations are evaluated, binds the name to the property.
606_Path = Path
609@export
610class BaseWithPath(Base):
611 """
612 Base-class of the physical hierarchy - directories and source files -: a name and a path below the report.
614 Its parent is the :class:`Directory` containing it.
615 """
617 _name: _Path #: Name of the directory or file: a path of one component.
619 def __init__(self, name: Path, *, parent: Nullable[Directory]) -> None:
620 """
621 Initializes the parent, the name and the counters.
623 :param name: Name of the directory or file: a path of one component.
624 :param parent: The directory containing this one, or ``None``.
625 :raises ValueError: If parameter ``name`` is ``None``.
626 :raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
627 :raises ValueError: If parameter ``name`` is empty or ``.``.
628 :raises ValueError: If parameter ``name`` has more than one component.
629 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`.
630 """
631 super().__init__(parent=parent)
633 if name is None:
634 raise ValueError("Parameter 'name' is None.")
635 elif not isinstance(name, Path):
636 ex = TypeError("Parameter 'name' is not of type 'Path'.")
637 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
638 raise ex
639 elif name.name == "":
640 ex = ValueError("Parameter 'name' is empty.")
641 ex.add_note(f"Got path '{name}'.")
642 raise ex
643 elif len(name.parts) > 1:
644 ex = ValueError("Parameter 'name' has more than one component.")
645 ex.add_note(f"Got path '{name.as_posix()}'.")
646 raise ex
648 self._name = name
650 @readonly
651 def Name(self) -> Path:
652 """
653 Read-only property to access the name of the directory or file (:attr:`_name`).
655 :returns: The name: a path of one component.
656 """
657 return self._name
659 @readonly
660 def Path(self) -> Path:
661 """
662 Read-only property to return the path below the report: the names of the parent directories and the own name.
664 A file ``Shapes.py`` in a directory ``myPackage`` has the path ``myPackage/Shapes.py``.
666 :returns: The path; the name, if there is no parent.
667 """
668 if self._parent is None:
669 return self._name
671 return self._parent.Path / self._name
674@export
675class Directory(BaseWithPath):
676 """
677 A directory: its directories and source files, and their summed counters.
678 """
680 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: A directory is in a directory; assigned below the class.
682 _directories: dict[_Path, Directory] #: The directories in this directory, by name.
683 _files: dict[_Path, File] #: The source files in this directory, by name.
685 def __init__(self, name: Path, *, parent: Nullable[Directory] = None) -> None:
686 """
687 Initializes a directory, and adds it to its parent directory.
689 :param name: Name of the directory: a path of one component.
690 :param parent: Optional, the directory containing this one. Default: ``None``.
691 :raises ValueError: If parameter ``name`` is ``None``.
692 :raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
693 :raises ValueError: If parameter ``name`` is empty or ``.``.
694 :raises ValueError: If parameter ``name`` has more than one component.
695 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`.
696 :raises DuplicateDirectoryError: If the parent directory already contains a file or directory of this name.
697 """
698 super().__init__(name, parent=parent)
700 self._directories = {}
701 self._files = {}
703 if parent is not None:
704 parent._AddElement(self)
706 def _AddElement(self, element: Directory | File) -> None:
707 """
708 Add a directory or file, which names this directory as its parent.
710 :param element: The directory or file.
711 :raises DuplicateDirectoryError: If this directory already contains a directory of this name.
712 :raises DuplicateFileError: If this directory already contains a file of this name.
713 """
714 if isinstance(element, Directory):
715 elements = self._directories
716 exception = DuplicateDirectoryError
717 else:
718 elements = self._files
719 exception = DuplicateFileError
721 if element._name in self._directories or element._name in self._files:
722 raise exception(f"Directory '{self.Path.as_posix()}' already contains '{element._name.as_posix()}'.")
724 elements[element._name] = element
726 def _SetRoot(self, root: Nullable[DocumentationReport]) -> None:
727 """
728 Set the analysis' root of this directory, and of the directories and files below it.
730 :param root: The root, or ``None`` while the directory isn't part of an analysis.
731 """
732 self._root = root
733 for directory in self._directories.values():
734 directory._SetRoot(root)
736 for file in self._files.values():
737 file._SetRoot(root)
739 @readonly
740 def Directories(self) -> dict[Path, Directory]:
741 """
742 Read-only property to access the directories in this directory (:attr:`_directories`).
744 :returns: The directories, by name.
745 """
746 return self._directories
748 @readonly
749 def Files(self) -> dict[Path, File]:
750 """
751 Read-only property to access the source files in this directory (:attr:`_files`).
753 :returns: The files, by name.
754 """
755 return self._files
757 @readonly
758 def FileCount(self) -> int:
759 """
760 Read-only property to return the number of source files in this directory and below.
762 :returns: The number of files.
763 """
764 return len(self._files) + sum(directory.FileCount for directory in self._directories.values())
766 def GetOrAddFile(self, path: Path) -> File:
767 """
768 Return the source file at a path below this directory, adding it and the directories on its way, if missing.
770 :param path: The file's path relative to this directory, e.g. ``myPackage/Shapes.py``.
771 :returns: The file.
772 :raises ValueError: If parameter ``path`` is ``None``.
773 :raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path`.
774 :raises ValueError: If parameter ``path`` names no file.
775 :raises DocCoverageError: If a part of the path is a file instead of a directory, or the file's name is a
776 directory's.
777 """
778 if path is None:
779 raise ValueError("Parameter 'path' is None.")
780 elif not isinstance(path, Path):
781 ex = TypeError("Parameter 'path' is not of type 'Path'.")
782 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
783 raise ex
785 parts = [Path(part) for part in path.parts if part not in ("", ".", path.anchor)]
786 if len(parts) == 0:
787 ex = ValueError("Parameter 'path' names no file.")
788 ex.add_note(f"Got path '{path}'.")
789 raise ex
791 directory = self
792 for part in parts[:-1]:
793 if part in directory._files:
794 raise DocCoverageError(f"'{(directory.Path / part).as_posix()}' is a file, not a directory.")
795 elif (subdirectory := directory._directories.get(part)) is None:
796 subdirectory = Directory(part, parent=directory)
797 directory = subdirectory
799 if (file := directory._files.get(parts[-1])) is None:
800 file = File(parts[-1], parent=directory)
802 return file
804 def IterateFiles(self) -> Generator[File, None, None]:
805 """
806 Iterate the source files in this directory and below.
808 A directory's files come before its directories' files, each in the order they were added.
810 :returns: A generator of the files.
811 """
812 yield from self._files.values()
813 for directory in self._directories.values():
814 yield from directory.IterateFiles()
816 def Aggregate(self) -> None:
817 """
818 Aggregate the directories and files in this directory, then sum their counters.
819 """
820 self._ResetCounters()
821 for child in (*self._directories.values(), *self._files.values()):
822 child.Aggregate()
823 self._AddCounters(child)
825 def __repr__(self) -> str:
826 """
827 Return a representation of the directory for debugging.
829 A directory ``myPackage`` of 3 files reads ``<Directory myPackage: 3 files, 75.0%>``.
831 :returns: The directory's path, its number of files and its documentation coverage.
832 """
833 return f"<Directory {self.Path.as_posix()}: {self.FileCount} files, {self._counters.Coverage:.1%}>"
836Directory._PARENT_TYPE = (Directory, )
839@export
840class DocumentationReport(Directory):
841 """
842 The report of a documentation coverage analysis: the directory the source files' paths are relative to, and the
843 top-level items of the logical hierarchy.
845 It is its own root. Its counters count every item of the logical hierarchy; its directories and files count only
846 the items declared in a source file.
847 """
849 _items: list[Item] #: The top-level items, in the order they were added.
850 _analysisDuration: Nullable[timedelta] #: Duration of the analysis, which produced the report, if measured.
852 def __init__(self, name: Path) -> None:
853 """
854 Initializes the report of a documentation coverage analysis.
856 :param name: Name of the report: a path of one component, e.g. the analyzed package's name.
857 :raises ValueError: If parameter ``name`` is ``None``.
858 :raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
859 :raises ValueError: If parameter ``name`` is empty or ``.``.
860 :raises ValueError: If parameter ``name`` has more than one component.
861 """
862 super().__init__(name)
864 self._root = self
865 self._items = []
866 self._analysisDuration = None
868 @Base.Parent.setter
869 def Parent(self, parent: None) -> None:
870 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.")
871 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
872 raise ex
874 def _AddElement(self, element: Directory | File | Item) -> None:
875 """
876 Add a top-level item, a directory or a file, which names the report as its parent.
878 :param element: The item, directory or file.
879 :raises DuplicateDirectoryError: If the report already contains a directory of this name.
880 :raises DuplicateFileError: If the report already contains a file of this name.
881 """
882 if isinstance(element, BaseWithPath):
883 super()._AddElement(element)
884 else:
885 self._items.append(element)
887 @readonly
888 def Path(self) -> Path:
889 """
890 Read-only property to return the path below the report, which is the report's own: ``.``.
892 So the paths of the directories and files below are relative to the report, without its name.
894 :returns: ``.``
895 """
896 return Path(".")
898 @readonly
899 def Items(self) -> list[Item]:
900 """
901 Read-only property to access the top-level items of the logical hierarchy (:attr:`_items`).
903 :returns: The items, in the order they were added.
904 """
905 return self._items
907 @readonly
908 def AnalysisDuration(self) -> Nullable[timedelta]:
909 """
910 Read-only property to access the duration of the analysis, which produced the report (:attr:`_analysisDuration`).
912 A reader measures it; a report built otherwise has none.
914 :returns: The duration, or ``None`` if it wasn't measured.
915 """
916 return self._analysisDuration
918 def Aggregate(self) -> None:
919 """
920 Aggregate the physical hierarchy - directories and files -, then the items of the logical hierarchy, which the
921 report's counters sum.
922 """
923 super().Aggregate()
925 self._ResetCounters()
926 for item in self._items:
927 item.Aggregate()
928 self._AddCounters(item)
931@export
932class File(BaseWithPath):
933 """
934 A source file: the items declared in it, and their counters.
936 The items are those of the logical hierarchy naming this file; the file counts each item's own state, not the items
937 it contains, which name their file themselves.
938 """
940 _PARENT_TYPE: ClassVar[tuple[type, ...]] = (Directory, ) #: A file is in a directory.
942 _items: list[Item] #: The items declared in this file, in the order they were added.
944 def __init__(self, name: Path, *, parent: Nullable[Directory] = None) -> None:
945 """
946 Initializes a source file, and adds it to its directory.
948 :param name: Name of the file: a path of one component.
949 :param parent: Optional, the directory containing the file. Default: ``None``.
950 :raises ValueError: If parameter ``name`` is ``None``.
951 :raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
952 :raises ValueError: If parameter ``name`` is empty or ``.``.
953 :raises ValueError: If parameter ``name`` has more than one component.
954 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`.
955 :raises DuplicateFileError: If the directory already contains a file or directory of this name.
956 """
957 super().__init__(name, parent=parent)
959 self._items = []
961 if parent is not None:
962 parent._AddElement(self)
964 @readonly
965 def Items(self) -> list[Item]:
966 """
967 Read-only property to access the items declared in this file (:attr:`_items`).
969 :returns: The items, in the order they were added.
970 """
971 return self._items
973 def Aggregate(self) -> None:
974 """
975 Count the own coverage state of each item declared in this file.
976 """
977 self._ResetCounters()
978 for item in self._items:
979 self._CountItem(item)
981 def __repr__(self) -> str:
982 """
983 Return a representation of the file for debugging.
985 A file ``Shapes.py`` of 4 items reads ``<File myPackage/Shapes.py: 4 items, 75.0%>``.
987 :returns: The file's path, its number of items and its documentation coverage.
988 """
989 return f"<File {self.Path.as_posix()}: {len(self._items)} items, {self._counters.Coverage:.1%}>"
992# A class with a property named like a class - ``File`` - can't name that class in the annotation of a field: the class
993# body's namespace, where annotations are evaluated, binds the name to the property.
994_File = File
997@export
998class Item(Base):
999 """
1000 A documentable item of a language, its coverage state, and the items it contains.
1002 Its parent is the :class:`Item` containing it, or the :class:`DocumentationReport` for a top-level item. Items of one
1003 parent may share a name, e.g. a property's getter and setter.
1005 A container's own documentation - a module's or a class' doc-string - is the container's own coverage state; the
1006 items it contains have states of their own. So an item's counters count the item itself and every item below it.
1007 """
1009 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: An item is in an item or the report; assigned below the class.
1011 _name: str #: Name of the item.
1012 _kind: ItemKind #: The language construct the item is.
1013 _status: CoverageState #: The item's own coverage state.
1014 _file: Nullable[_File] #: The source file the item is declared in, if the reader knows it.
1015 _startLine: Nullable[int] #: The item's first line, if the reader knows it.
1016 _endLine: Nullable[int] #: The item's last line, if the reader knows it.
1017 _items: list[Item] #: The items this one contains, in the order they were added.
1019 def __init__(
1020 self,
1021 name: str,
1022 kind: ItemKind,
1023 status: CoverageState,
1024 *,
1025 file: Nullable[File] = None,
1026 startLine: Nullable[int] = None,
1027 endLine: Nullable[int] = None,
1028 parent: Nullable[Item | DocumentationReport] = None
1029 ) -> None:
1030 """
1031 Initializes an item, and adds it to its file and to the item or report containing it.
1033 :param name: Name of the item.
1034 :param kind: The language construct the item is.
1035 :param status: The item's own coverage state.
1036 :param file: Optional, the source file the item is declared in. Default: ``None``.
1037 :param startLine: Optional, the item's first line. Default: ``None``.
1038 :param endLine: Optional, the item's last line. Default: ``None``.
1039 :param parent: Optional, the item or report containing this item. Default: ``None``.
1040 :raises ValueError: If parameter ``name`` is ``None``.
1041 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
1042 :raises ValueError: If parameter ``name`` is empty.
1043 :raises ValueError: If parameter ``kind`` is ``None``.
1044 :raises TypeError: If parameter ``kind`` isn't of type :class:`ItemKind`.
1045 :raises ValueError: If parameter ``status`` is ``None``.
1046 :raises TypeError: If parameter ``status`` isn't of type :class:`CoverageState`.
1047 :raises ValueError: If parameter ``status`` is ``CoverageState.Unknown``.
1048 :raises TypeError: If parameter ``file`` isn't of type :class:`File`.
1049 :raises TypeError: If parameter ``startLine`` isn't of type :class:`int`.
1050 :raises ValueError: If parameter ``startLine`` is less than 1.
1051 :raises TypeError: If parameter ``endLine`` isn't of type :class:`int`.
1052 :raises ValueError: If parameter ``endLine`` is before parameter ``startLine``, or less than 1.
1053 :raises TypeError: If parameter ``parent`` isn't of type :class:`Item` or :class:`DocumentationReport`.
1054 """
1055 super().__init__(parent=parent)
1057 if name is None:
1058 raise ValueError("Parameter 'name' is None.")
1059 elif not isinstance(name, str):
1060 ex = TypeError("Parameter 'name' is not of type 'str'.")
1061 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
1062 raise ex
1063 elif name == "":
1064 raise ValueError("Parameter 'name' is empty.")
1066 if kind is None:
1067 raise ValueError("Parameter 'kind' is None.")
1068 elif not isinstance(kind, ItemKind):
1069 ex = TypeError("Parameter 'kind' is not of type 'ItemKind'.")
1070 ex.add_note(f"Got type '{getFullyQualifiedName(kind)}'.")
1071 raise ex
1073 if status is None:
1074 raise ValueError("Parameter 'status' is None.")
1075 elif not isinstance(status, CoverageState):
1076 ex = TypeError("Parameter 'status' is not of type 'CoverageState'.")
1077 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.")
1078 raise ex
1079 elif status is CoverageState.Unknown:
1080 raise ValueError("Parameter 'status' is 'Unknown'.")
1082 if file is not None and not isinstance(file, File):
1083 ex = TypeError("Parameter 'file' is not of type 'File'.")
1084 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.")
1085 raise ex
1087 if startLine is not None:
1088 if not isinstance(startLine, int):
1089 ex = TypeError("Parameter 'startLine' is not of type 'int'.")
1090 ex.add_note(f"Got type '{getFullyQualifiedName(startLine)}'.")
1091 raise ex
1092 elif startLine < 1:
1093 ex = ValueError("Parameter 'startLine' is less than 1.")
1094 ex.add_note(f"Got value '{startLine}'.")
1095 raise ex
1097 if endLine is not None:
1098 if not isinstance(endLine, int):
1099 ex = TypeError("Parameter 'endLine' is not of type 'int'.")
1100 ex.add_note(f"Got type '{getFullyQualifiedName(endLine)}'.")
1101 raise ex
1102 elif endLine < (1 if startLine is None else startLine):
1103 ex = ValueError("Parameter 'endLine' is before parameter 'startLine', or less than 1.")
1104 ex.add_note(f"Got lines {startLine} to {endLine}.")
1105 raise ex
1107 self._name = name
1108 self._kind = kind
1109 self._status = status
1110 self._file = file
1111 self._startLine = startLine
1112 self._endLine = endLine
1113 self._items = []
1115 if parent is not None:
1116 parent._AddElement(self)
1118 if file is not None:
1119 file._items.append(self)
1121 def _AddElement(self, item: Item) -> None:
1122 """
1123 Add an item, which names this item as its parent.
1125 :param item: The item.
1126 """
1127 self._items.append(item)
1129 def _SetRoot(self, root: Nullable[DocumentationReport]) -> None:
1130 """
1131 Set the analysis' root of this item, and of the items below it.
1133 :param root: The root, or ``None`` while the item isn't part of an analysis.
1134 """
1135 self._root = root
1136 for item in self._items:
1137 item._SetRoot(root)
1139 def IterateDescendants(self, predicate: Nullable[Callable[[Item], bool]] = None) -> Generator[Item, None, None]:
1140 """
1141 Iterate all or selected items below this one - the items it contains, theirs, ... -, depth-first: each item
1142 followed by the items below it.
1144 If parameter ``predicate`` is not None, the given filter function is used to skip items in the generator. A skipped
1145 item's items are still iterated.
1147 :param predicate: Optional, filter function accepting an item and returning a boolean. Default: ``None``.
1148 :returns: A generator of the items below this one.
1149 """
1150 if predicate is None:
1151 for item in self._items:
1152 yield item
1153 yield from item.IterateDescendants()
1154 else:
1155 for item in self._items:
1156 if predicate(item):
1157 yield item
1159 yield from item.IterateDescendants(predicate)
1161 @readonly
1162 def Name(self) -> str:
1163 """
1164 Read-only property to access the item's name (:attr:`_name`).
1166 :returns: The name.
1167 """
1168 return self._name
1170 @readonly
1171 def Kind(self) -> ItemKind:
1172 """
1173 Read-only property to access the language construct the item is (:attr:`_kind`).
1175 :returns: The item's kind.
1176 """
1177 return self._kind
1179 @readonly
1180 def Category(self) -> ItemCategory:
1181 """
1182 Read-only property to access the category of the item's kind (:attr:`_kind`).
1184 :returns: The item's category.
1185 """
1186 return self._kind.Category
1188 @readonly
1189 def Status(self) -> CoverageState:
1190 """
1191 Read-only property to access the item's own coverage state (:attr:`_status`).
1193 :returns: The coverage state.
1194 """
1195 return self._status
1197 @readonly
1198 def File(self) -> Nullable[File]:
1199 """
1200 Read-only property to access the source file the item is declared in (:attr:`_file`).
1202 :returns: The file, or ``None`` if the reader doesn't know it.
1203 """
1204 return self._file
1206 @readonly
1207 def StartLine(self) -> Nullable[int]:
1208 """
1209 Read-only property to access the item's first line (:attr:`_startLine`).
1211 :returns: The line number, or ``None`` if the reader doesn't know it.
1212 """
1213 return self._startLine
1215 @readonly
1216 def EndLine(self) -> Nullable[int]:
1217 """
1218 Read-only property to access the item's last line (:attr:`_endLine`).
1220 :returns: The line number, or ``None`` if the reader doesn't know it.
1221 """
1222 return self._endLine
1224 @readonly
1225 def Items(self) -> list[Item]:
1226 """
1227 Read-only property to access the items this one contains (:attr:`_items`).
1229 :returns: The items, in the order they were added.
1230 """
1231 return self._items
1233 @readonly
1234 def QualifiedName(self) -> str:
1235 """
1236 Read-only property to return the names of the items from the top down to this one, joined by ``.``.
1238 A method ``Area`` of a class ``Circle`` in a module ``myPackage.Shapes`` has the qualified name
1239 ``myPackage.Shapes.Circle.Area``.
1241 :returns: The qualified name.
1242 """
1243 if isinstance(self._parent, Item):
1244 return f"{self._parent.QualifiedName}.{self._name}"
1246 return self._name
1248 def __len__(self) -> int:
1249 """
1250 Return the number of items this one contains.
1252 :returns: The number of items.
1253 """
1254 return len(self._items)
1256 def __iter__(self) -> Iterator[Item]:
1257 """
1258 Iterate the items this one contains.
1260 :returns: An iterator of the items, in the order they were added.
1261 """
1262 return iter(self._items)
1264 def __contains__(self, name: str) -> bool:
1265 """
1266 Return whether this item contains an item of a name.
1268 :param name: The name.
1269 :returns: ``True``, if an item of this name is contained.
1270 """
1271 return any(item._name == name for item in self._items)
1273 def __getitem__(self, name: str) -> Item:
1274 """
1275 Return the contained item of a name; the first one, if several share it.
1277 A property's getter is added before its setter, so it is the one returned.
1279 :param name: The name.
1280 :returns: The item.
1281 :raises KeyError: If no item of this name is contained.
1282 """
1283 for item in self._items:
1284 if item._name == name:
1285 return item
1287 raise KeyError(f"Item '{self.QualifiedName}' contains no item '{name}'.")
1289 def Aggregate(self) -> None:
1290 """
1291 Aggregate the items this one contains, then count this item's own coverage state and add their counters.
1292 """
1293 self._ResetCounters()
1294 self._CountItem(self)
1295 for item in self._items:
1296 item.Aggregate()
1297 self._AddCounters(item)
1299 def __repr__(self) -> str:
1300 """
1301 Return a representation of the item for debugging.
1303 A method ``Area`` of class ``Circle`` in module ``Shapes`` reads ``<Item Method Shapes.Circle.Area: 100.0%>``.
1305 :returns: The item's kind, qualified name and documentation coverage.
1306 """
1307 return f"<Item {self._kind.name} {self.QualifiedName}: {self._counters.Coverage:.1%}>"
1310Item._PARENT_TYPE = (Item, DocumentationReport)