Coverage for pyEDAA/Reports/CodeCoverage/CoveragePy.py: 90%
489 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"""
32coverage.py's JSON code coverage format: a model of the format, read from a report and converted to the common model.
34coverage.py writes the format with ``coverage json``. A report is validated against the JSON Schema of the format
35version it states (:class:`FormatVersion`), reverse-engineered from coverage.py: :file:`CoveragePy-2.schema.json` for
36format 2 (coverage.py 7.4.1 to 7.5), :file:`CoveragePy-3.schema.json` for format 3 (coverage.py 7.6 and later). The
37format's model keeps what the report states: a :class:`Document` holds :class:`File` records, a file - in format 3 -
38:class:`Region` records of its functions and classes, and each its lines, branches and :class:`Summary`. Each
39record's constructor takes typed values, so the model can be built by hand: a file or a region names its parent with
40the keyword parameter ``parent`` and is added to it. Its class method ``Parse`` reads the record's JSON object.
42:meth:`Document.ToCoverageSummary` converts the model to the common model of :mod:`pyEDAA.Reports.CodeCoverage`:
44* A file's path is relative to the directory coverage.py ran in.
45* Executed lines are covered - partially covered, if one of their branches wasn't taken -, missing lines uncovered,
46 excluded lines excluded. The format has no counts.
47* A branch is a pair of source and destination line; it becomes a branch of its source line, naming its target line -
48 none for an exit of a function, which coverage.py states as a negative number.
49* A file's directories become packages, the file a module spanning the whole file; its classes and functions - named by
50 qualified names like ``Circle.Area`` - become classes, methods and functions, each spanning its ``class`` or ``def``
51 line to its last line. coverage.py's own summary of a function counts the ``def`` line for the enclosing scope.
53.. rubric:: Example
55.. code-block:: Python
57 from pathlib import Path
58 from pyEDAA.Reports.CodeCoverage.CoveragePy import Document
60 report = Document(Path("coverage.json"), analyzeAndConvert=True)
61 summary = report.ToCoverageSummary()
62 print(f"{summary.FileCount} files: {summary.Coverage:.1%}")
63"""
64from __future__ import annotations
66from datetime import datetime
67from enum import Enum, IntEnum
68from json import JSONDecodeError, loads
69from pathlib import Path
70from typing import Any, Iterable, Optional as Nullable, Self
72from pyTooling.Common import getFullyQualifiedName, readResourceFile
73from pyTooling.Decorators import export, readonly
74from pyTooling.Exceptions import ToolingException
75from pyTooling.MetaClasses import ExtendedType, abstractclass
76from pyTooling.Stopwatch import Stopwatch
77from pyTooling.Versioning import SemanticVersion
79from pyEDAA.Reports import Resources
80from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, Class as cc_Class, CodeCoverageError, CoverageSummary, \
81 Document as cc_Document, File as cc_File, Function as cc_Function, \
82 Line as cc_Line, LineCoverageStatus, Method as cc_Method, Module as cc_Module, \
83 Package as cc_Package, Unit as cc_Unit
86__all__ = ["SCHEMAS"]
88# A class with a property named like a class - ``Path``, ``Summary`` - can't name that class in the annotation of a
89# field: the class body's namespace, where annotations are evaluated, binds the name to the property.
90_Path = Path
93@export
94class FormatVersion(IntEnum):
95 """
96 Version of coverage.py's JSON report format, as a report's ``meta.format`` states it.
98 coverage.py 7.4.1 added ``meta.format`` with format 2. coverage.py 7.6 and later write format 3, which adds the
99 functions and classes of a file. A report of an earlier coverage.py states no format version and isn't supported.
100 """
102 Version2 = 2 #: Format 2, written by coverage.py 7.4.1 to 7.5.
103 Version3 = 3 #: Format 3, written by coverage.py 7.6 and later.
105 @classmethod
106 def Parse(cls, value: int) -> Self:
107 """
108 Convert the version, as a report's ``meta.format`` states it, to the member of that version.
110 :param value: The version.
111 :returns: The member of that version.
112 :raises ValueError: If parameter ``value`` is ``None``.
113 :raises TypeError: If parameter ``value`` is not of type :class:`int`.
114 :raises ValueError: If parameter ``value`` is not a supported format version.
115 """
116 if value is None:
117 raise ValueError("Parameter 'value' is None.")
118 elif not isinstance(value, int) or isinstance(value, bool):
119 ex = TypeError("Parameter 'value' is not of type 'int'.")
120 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
121 raise ex
122 elif value not in [member.value for member in cls]:
123 ex = ValueError("Parameter 'value' is not a supported coverage.py JSON format version.")
124 ex.add_note(f"Got value '{value}'.")
125 ex.add_note(f"Supported format versions: {', '.join(str(member.value) for member in cls)}.")
126 raise ex
128 return cls(value)
131SCHEMAS: dict[FormatVersion, str] = {
132 FormatVersion.Version2: "CoveragePy-2.schema.json",
133 FormatVersion.Version3: "CoveragePy-3.schema.json"
134} #: Per format version, the JSON Schema a report of that version is validated against.
137@export
138class Summary(metaclass=ExtendedType, slots=True):
139 """
140 A ``summary``: the counters coverage.py computed for the whole report, a file or a region.
142 coverage.py measures lines. What it calls a statement (``num_statements``) is a line number: a statement spanning
143 several lines counts by its first line, several statements on one line count once.
144 """
146 _lineCount: int #: Number of executable lines, without the excluded ones.
147 _coveredLineCount: int #: Number of executed lines.
148 _missingLineCount: int #: Number of executable lines, which never ran.
149 _excludedLineCount: int #: Number of excluded lines.
150 _percentCovered: float #: Coverage of lines and branches, in percent.
151 _branchCount: Nullable[int] #: Number of branches, if branch coverage was measured.
152 _coveredBranchCount: Nullable[int] #: Number of taken branches, if branch coverage was measured.
153 _partialBranchCount: Nullable[int] #: Number of branches never taken from executed lines, if branches were measured.
155 def __init__(
156 self,
157 lineCount: int,
158 coveredLineCount: int,
159 missingLineCount: int,
160 excludedLineCount: int,
161 percentCovered: float,
162 branchCount: Nullable[int] = None,
163 coveredBranchCount: Nullable[int] = None,
164 partialBranchCount: Nullable[int] = None
165 ) -> None:
166 """
167 Initialize the summary from its counters.
169 :param lineCount: Number of executable lines, without the excluded ones.
170 :param coveredLineCount: Number of executed lines.
171 :param missingLineCount: Number of executable lines, which never ran.
172 :param excludedLineCount: Number of excluded lines.
173 :param percentCovered: Coverage of lines and branches, in percent.
174 :param branchCount: Optional, number of branches. Default: ``None`` (branch coverage wasn't measured).
175 :param coveredBranchCount: Optional, number of taken branches. Default: ``None`` (branch coverage wasn't measured).
176 :param partialBranchCount: Optional, number of branches never taken from executed lines. |br|
177 Default: ``None`` (branch coverage wasn't measured).
178 :raises ValueError: If parameter ``lineCount`` is ``None``.
179 :raises TypeError: If parameter ``lineCount`` is not of type :class:`int`.
180 :raises ValueError: If parameter ``lineCount`` is negative.
181 :raises ValueError: If parameter ``coveredLineCount`` is ``None``.
182 :raises TypeError: If parameter ``coveredLineCount`` is not of type :class:`int`.
183 :raises ValueError: If parameter ``coveredLineCount`` is negative.
184 :raises ValueError: If parameter ``missingLineCount`` is ``None``.
185 :raises TypeError: If parameter ``missingLineCount`` is not of type :class:`int`.
186 :raises ValueError: If parameter ``missingLineCount`` is negative.
187 :raises ValueError: If parameter ``excludedLineCount`` is ``None``.
188 :raises TypeError: If parameter ``excludedLineCount`` is not of type :class:`int`.
189 :raises ValueError: If parameter ``excludedLineCount`` is negative.
190 :raises ValueError: If parameter ``percentCovered`` is ``None``.
191 :raises TypeError: If parameter ``percentCovered`` is not of type :class:`float`.
192 :raises ValueError: If parameter ``percentCovered`` is out of range 0..100.
193 :raises TypeError: If parameter ``branchCount`` is not of type :class:`int`.
194 :raises ValueError: If parameter ``branchCount`` is negative.
195 :raises TypeError: If parameter ``coveredBranchCount`` is not of type :class:`int`.
196 :raises ValueError: If parameter ``coveredBranchCount`` is negative.
197 :raises TypeError: If parameter ``partialBranchCount`` is not of type :class:`int`.
198 :raises ValueError: If parameter ``partialBranchCount`` is negative.
199 """
200 for name, count in (
201 ("lineCount", lineCount),
202 ("coveredLineCount", coveredLineCount),
203 ("missingLineCount", missingLineCount),
204 ("excludedLineCount", excludedLineCount)
205 ):
206 if count is None:
207 raise ValueError(f"Parameter '{name}' is None.")
208 elif not isinstance(count, int):
209 ex = TypeError(f"Parameter '{name}' is not of type 'int'.")
210 ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.")
211 raise ex
212 elif count < 0: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 ex = ValueError(f"Parameter '{name}' is negative.")
214 ex.add_note(f"Got value '{count}'.")
215 raise ex
217 if percentCovered is None: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 raise ValueError("Parameter 'percentCovered' is None.")
219 elif not isinstance(percentCovered, (int, float)): 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true
220 ex = TypeError("Parameter 'percentCovered' is not of type 'float'.")
221 ex.add_note(f"Got type '{getFullyQualifiedName(percentCovered)}'.")
222 raise ex
223 elif not 0 <= percentCovered <= 100:
224 ex = ValueError("Parameter 'percentCovered' is out of range 0..100.")
225 ex.add_note(f"Got value '{percentCovered}'.")
226 raise ex
228 for name, count in (
229 ("branchCount", branchCount),
230 ("coveredBranchCount", coveredBranchCount),
231 ("partialBranchCount", partialBranchCount)
232 ):
233 if count is None:
234 continue
235 elif not isinstance(count, int): 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true
236 ex = TypeError(f"Parameter '{name}' is not of type 'int'.")
237 ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.")
238 raise ex
239 elif count < 0:
240 ex = ValueError(f"Parameter '{name}' is negative.")
241 ex.add_note(f"Got value '{count}'.")
242 raise ex
244 self._lineCount = lineCount
245 self._coveredLineCount = coveredLineCount
246 self._missingLineCount = missingLineCount
247 self._excludedLineCount = excludedLineCount
248 self._percentCovered = percentCovered
249 self._branchCount = branchCount
250 self._coveredBranchCount = coveredBranchCount
251 self._partialBranchCount = partialBranchCount
253 @classmethod
254 def Parse(cls, summary: dict[str, Any]) -> Self:
255 """
256 Read a summary from its JSON object.
258 :param summary: The JSON object ``summary`` or ``totals``.
259 :returns: The summary.
260 :raises ValueError: If parameter ``summary`` is ``None``.
261 :raises TypeError: If parameter ``summary`` is not of type :class:`dict`.
262 """
263 if summary is None:
264 raise ValueError("Parameter 'summary' is None.")
265 elif not isinstance(summary, dict): 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 ex = TypeError("Parameter 'summary' is not of type 'dict'.")
267 ex.add_note(f"Got type '{getFullyQualifiedName(summary)}'.")
268 raise ex
270 return cls(
271 summary["num_statements"],
272 summary["covered_lines"],
273 summary["missing_lines"],
274 summary["excluded_lines"],
275 summary["percent_covered"],
276 summary.get("num_branches"),
277 summary.get("covered_branches"),
278 summary.get("num_partial_branches")
279 )
281 @readonly
282 def LineCount(self) -> int:
283 """
284 Read-only property to access the number of executable lines, without the excluded ones (:attr:`_lineCount`).
286 coverage.py states it as ``num_statements``.
288 :returns: The number of executable lines.
289 """
290 return self._lineCount
292 @readonly
293 def CoveredLineCount(self) -> int:
294 """
295 Read-only property to access the number of executed lines (:attr:`_coveredLineCount`).
297 :returns: The number of executed lines.
298 """
299 return self._coveredLineCount
301 @readonly
302 def MissingLineCount(self) -> int:
303 """
304 Read-only property to access the number of executable lines, which never ran (:attr:`_missingLineCount`).
306 :returns: The number of missing lines.
307 """
308 return self._missingLineCount
310 @readonly
311 def ExcludedLineCount(self) -> int:
312 """
313 Read-only property to access the number of excluded lines (:attr:`_excludedLineCount`).
315 :returns: The number of excluded lines.
316 """
317 return self._excludedLineCount
319 @readonly
320 def PercentCovered(self) -> float:
321 """
322 Read-only property to access the coverage of lines and branches (:attr:`_percentCovered`).
324 :returns: The coverage in percent.
325 """
326 return self._percentCovered
328 @readonly
329 def BranchCount(self) -> Nullable[int]:
330 """
331 Read-only property to access the number of branches (:attr:`_branchCount`).
333 :returns: The number of branches, or ``None`` if branch coverage wasn't measured.
334 """
335 return self._branchCount
337 @readonly
338 def CoveredBranchCount(self) -> Nullable[int]:
339 """
340 Read-only property to access the number of taken branches (:attr:`_coveredBranchCount`).
342 :returns: The number of taken branches, or ``None`` if branch coverage wasn't measured.
343 """
344 return self._coveredBranchCount
346 @readonly
347 def PartialBranchCount(self) -> Nullable[int]:
348 """
349 Read-only property to access the number of branches never taken from executed lines (:attr:`_partialBranchCount`).
351 :returns: The number of branches, which were never taken although their line ran, or ``None`` if branch coverage
352 wasn't measured.
353 """
354 return self._partialBranchCount
357_Summary = Summary
360@export
361@abstractclass
362class Base(metaclass=ExtendedType, slots=True):
363 """
364 Base-class of a file and a region: its executed, missing and excluded lines, its branches, and its summary.
365 """
367 _executedLines: list[int] #: The executed lines.
368 _missingLines: list[int] #: The lines, which never ran.
369 _excludedLines: list[int] #: The excluded lines.
370 _executedBranches: list[tuple[int, int]] #: The taken branches, as pairs of source and destination line.
371 _missingBranches: list[tuple[int, int]] #: The branches never taken, as pairs of source and destination line.
372 _summary: _Summary #: The counters coverage.py computed.
374 def __init__(
375 self,
376 summary: Summary,
377 executedLines: Nullable[Iterable[int]] = None,
378 missingLines: Nullable[Iterable[int]] = None,
379 excludedLines: Nullable[Iterable[int]] = None,
380 executedBranches: Nullable[Iterable[tuple[int, int]]] = None,
381 missingBranches: Nullable[Iterable[tuple[int, int]]] = None
382 ) -> None:
383 """
384 Initialize the summary, the lines and the branches.
386 :param summary: The counters coverage.py computed.
387 :param executedLines: Optional, the executed lines. Default: ``None`` (none).
388 :param missingLines: Optional, the lines, which never ran. Default: ``None`` (none).
389 :param excludedLines: Optional, the excluded lines. Default: ``None`` (none).
390 :param executedBranches: Optional, the taken branches, as pairs of source and destination line. |br|
391 Default: ``None`` (none, or branch coverage wasn't measured).
392 :param missingBranches: Optional, the branches never taken, as pairs of source and destination line. |br|
393 Default: ``None`` (none, or branch coverage wasn't measured).
394 :raises ValueError: If parameter ``summary`` is ``None``.
395 :raises TypeError: If parameter ``summary`` is not of type :class:`Summary`.
396 :raises TypeError: If parameter ``executedLines`` is not iterable.
397 :raises TypeError: If an element of parameter ``executedLines`` is not of type :class:`int`.
398 :raises TypeError: If parameter ``missingLines`` is not iterable.
399 :raises TypeError: If an element of parameter ``missingLines`` is not of type :class:`int`.
400 :raises TypeError: If parameter ``excludedLines`` is not iterable.
401 :raises TypeError: If an element of parameter ``excludedLines`` is not of type :class:`int`.
402 :raises TypeError: If parameter ``executedBranches`` is not iterable.
403 :raises TypeError: If an element of parameter ``executedBranches`` is not a pair of :class:`int`.
404 :raises TypeError: If parameter ``missingBranches`` is not iterable.
405 :raises TypeError: If an element of parameter ``missingBranches`` is not a pair of :class:`int`.
406 """
407 if summary is None:
408 raise ValueError("Parameter 'summary' is None.")
409 elif not isinstance(summary, Summary): 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true
410 ex = TypeError("Parameter 'summary' is not of type 'Summary'.")
411 ex.add_note(f"Got type '{getFullyQualifiedName(summary)}'.")
412 raise ex
414 lineLists: list[list[int]] = []
415 for name, numbers in (
416 ("executedLines", executedLines),
417 ("missingLines", missingLines),
418 ("excludedLines", excludedLines)
419 ):
420 lineList: list[int] = []
421 if numbers is not None:
422 if not isinstance(numbers, Iterable): 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true
423 ex = TypeError(f"Parameter '{name}' is not iterable.")
424 ex.add_note(f"Got type '{getFullyQualifiedName(numbers)}'.")
425 raise ex
427 for number in numbers:
428 if not isinstance(number, int):
429 ex = TypeError(f"An element of parameter '{name}' is not of type 'int'.")
430 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
431 raise ex
433 lineList.append(number)
434 lineLists.append(lineList)
436 branchLists: list[list[tuple[int, int]]] = []
437 for name, branches in (
438 ("executedBranches", executedBranches),
439 ("missingBranches", missingBranches)
440 ):
441 branchList: list[tuple[int, int]] = []
442 if branches is not None:
443 if not isinstance(branches, Iterable): 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true
444 ex = TypeError(f"Parameter '{name}' is not iterable.")
445 ex.add_note(f"Got type '{getFullyQualifiedName(branches)}'.")
446 raise ex
448 for branch in branches:
449 if not (isinstance(branch, tuple) and len(branch) == 2 and all(isinstance(line, int) for line in branch)):
450 ex = TypeError(f"An element of parameter '{name}' is not a pair of 'int'.")
451 ex.add_note(f"Got '{branch!r}' of type '{getFullyQualifiedName(branch)}'.")
452 raise ex
454 branchList.append(branch)
455 branchLists.append(branchList)
457 self._executedLines, self._missingLines, self._excludedLines = lineLists
458 self._executedBranches, self._missingBranches = branchLists
459 self._summary = summary
461 @readonly
462 def ExecutedLines(self) -> list[int]:
463 """
464 Read-only property to access the executed lines (:attr:`_executedLines`).
466 :returns: The line numbers.
467 """
468 return self._executedLines
470 @readonly
471 def MissingLines(self) -> list[int]:
472 """
473 Read-only property to access the lines, which never ran (:attr:`_missingLines`).
475 :returns: The line numbers.
476 """
477 return self._missingLines
479 @readonly
480 def ExcludedLines(self) -> list[int]:
481 """
482 Read-only property to access the excluded lines (:attr:`_excludedLines`).
484 :returns: The line numbers.
485 """
486 return self._excludedLines
488 @readonly
489 def ExecutedBranches(self) -> list[tuple[int, int]]:
490 """
491 Read-only property to access the taken branches (:attr:`_executedBranches`).
493 :returns: The branches as pairs of source and destination line; empty, if branch coverage wasn't measured.
494 """
495 return self._executedBranches
497 @readonly
498 def MissingBranches(self) -> list[tuple[int, int]]:
499 """
500 Read-only property to access the branches never taken (:attr:`_missingBranches`).
502 :returns: The branches as pairs of source and destination line; empty, if branch coverage wasn't measured.
503 """
504 return self._missingBranches
506 @readonly
507 def Summary(self) -> Summary:
508 """
509 Read-only property to access the counters coverage.py computed (:attr:`_summary`).
511 :returns: The summary.
512 """
513 return self._summary
515 @readonly
516 def AllLines(self) -> list[int]:
517 """
518 Read-only property to return the executed, missing and excluded lines.
520 :returns: The line numbers, sorted.
521 """
522 return sorted({*self._executedLines, *self._missingLines, *self._excludedLines})
525@export
526class RegionKind(Enum):
527 """
528 Kind of a region of a file: a function or a class.
530 A file states its regions of each kind in their own JSON object: ``functions`` and ``classes``.
531 """
533 Function = 1 #: A function or a method, listed in ``functions``.
534 Class = 2 #: A class, listed in ``classes``.
537@export
538class Region(Base):
539 """
540 A function or a class of a file - in format 3 -, named by its qualified name.
542 A method ``Area`` of a class ``Circle`` is named ``Circle.Area``.
543 """
545 _parent: Nullable[File] #: The file the region belongs to.
546 _name: str #: Qualified name of the function or class.
547 _kind: RegionKind #: Kind of the region: a function or a class.
548 _startLine: int #: The region's first line.
550 def __init__(
551 self,
552 name: str,
553 kind: RegionKind,
554 startLine: int,
555 summary: Summary,
556 executedLines: Nullable[Iterable[int]] = None,
557 missingLines: Nullable[Iterable[int]] = None,
558 excludedLines: Nullable[Iterable[int]] = None,
559 executedBranches: Nullable[Iterable[tuple[int, int]]] = None,
560 missingBranches: Nullable[Iterable[tuple[int, int]]] = None,
561 *,
562 parent: Nullable[File] = None
563 ) -> None:
564 """
565 Initialize the region from its name, its kind, its first line, its summary, its lines and its branches, and add it
566 to the functions or classes of its file.
568 :param name: Qualified name of the function or class, e.g. ``Circle.Area``.
569 :param kind: Kind of the region: a function or a class.
570 :param startLine: The region's first line.
571 :param summary: The counters coverage.py computed.
572 :param executedLines: Optional, the executed lines. Default: ``None`` (none).
573 :param missingLines: Optional, the lines, which never ran. Default: ``None`` (none).
574 :param excludedLines: Optional, the excluded lines. Default: ``None`` (none).
575 :param executedBranches: Optional, the taken branches, as pairs of source and destination line. |br|
576 Default: ``None`` (none, or branch coverage wasn't measured).
577 :param missingBranches: Optional, the branches never taken, as pairs of source and destination line. |br|
578 Default: ``None`` (none, or branch coverage wasn't measured).
579 :param parent: Optional, the file the region belongs to; the region is added to its functions or classes
580 by :attr:`Name`, as its :attr:`Kind` says. Default: ``None``.
581 :raises ValueError: If parameter ``name`` is ``None``.
582 :raises TypeError: If parameter ``name`` is not of type :class:`str`.
583 :raises ValueError: If parameter ``name`` is empty.
584 :raises ValueError: If parameter ``kind`` is ``None``.
585 :raises TypeError: If parameter ``kind`` is not of type :class:`RegionKind`.
586 :raises ValueError: If parameter ``startLine`` is ``None``.
587 :raises TypeError: If parameter ``startLine`` is not of type :class:`int`.
588 :raises ValueError: If parameter ``startLine`` is less than 1.
589 :raises TypeError: If parameter ``parent`` is not of type :class:`File`.
590 :raises ValueError: If parameter ``parent`` contains a region of the same kind and name already.
591 """
592 super().__init__(summary, executedLines, missingLines, excludedLines, executedBranches, missingBranches)
594 if name is None: 594 ↛ 595line 594 didn't jump to line 595 because the condition on line 594 was never true
595 raise ValueError("Parameter 'name' is None.")
596 elif not isinstance(name, str): 596 ↛ 597line 596 didn't jump to line 597 because the condition on line 596 was never true
597 ex = TypeError("Parameter 'name' is not of type 'str'.")
598 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
599 raise ex
600 elif name == "":
601 raise ValueError("Parameter 'name' is empty.")
603 if kind is None: 603 ↛ 604line 603 didn't jump to line 604 because the condition on line 603 was never true
604 raise ValueError("Parameter 'kind' is None.")
605 elif not isinstance(kind, RegionKind):
606 ex = TypeError("Parameter 'kind' is not of type 'RegionKind'.")
607 ex.add_note(f"Got type '{getFullyQualifiedName(kind)}'.")
608 raise ex
610 if startLine is None: 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true
611 raise ValueError("Parameter 'startLine' is None.")
612 elif not isinstance(startLine, int): 612 ↛ 613line 612 didn't jump to line 613 because the condition on line 612 was never true
613 ex = TypeError("Parameter 'startLine' is not of type 'int'.")
614 ex.add_note(f"Got type '{getFullyQualifiedName(startLine)}'.")
615 raise ex
616 elif startLine < 1:
617 ex = ValueError("Parameter 'startLine' is less than 1.")
618 ex.add_note(f"Got value '{startLine}'.")
619 raise ex
621 if parent is not None:
622 if not isinstance(parent, File):
623 ex = TypeError("Parameter 'parent' is not of type 'File'.")
624 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
625 raise ex
627 regions = parent._functions if kind is RegionKind.Function else parent._classes
628 if name in regions:
629 raise ValueError(f"Parameter 'parent' contains {kind.name.lower()} '{name}' already.")
631 self._parent = parent
632 self._name = name
633 self._kind = kind
634 self._startLine = startLine
636 if parent is not None:
637 regions[name] = self
639 @classmethod
640 def Parse(cls, name: str, kind: RegionKind, region: dict[str, Any], *, parent: Nullable[File] = None) -> Self:
641 """
642 Read a region from its JSON object.
644 :param name: Qualified name of the function or class: the region's key in ``functions`` or ``classes``.
645 :param kind: Kind of the region: :attr:`RegionKind.Function` in ``functions``, :attr:`RegionKind.Class` in
646 ``classes``.
647 :param region: The JSON object of the region.
648 :param parent: Optional, the file the region belongs to. Default: ``None``.
649 :returns: The region.
650 :raises ValueError: If parameter ``region`` is ``None``.
651 :raises TypeError: If parameter ``region`` is not of type :class:`dict`.
652 """
653 if region is None: 653 ↛ 654line 653 didn't jump to line 654 because the condition on line 653 was never true
654 raise ValueError("Parameter 'region' is None.")
655 elif not isinstance(region, dict):
656 ex = TypeError("Parameter 'region' is not of type 'dict'.")
657 ex.add_note(f"Got type '{getFullyQualifiedName(region)}'.")
658 raise ex
660 return cls(
661 name,
662 kind,
663 region["start_line"],
664 Summary.Parse(region["summary"]),
665 region["executed_lines"],
666 region["missing_lines"],
667 region["excluded_lines"],
668 (tuple(branch) for branch in region.get("executed_branches", [])),
669 (tuple(branch) for branch in region.get("missing_branches", [])),
670 parent=parent
671 )
673 @readonly
674 def Parent(self) -> Nullable[File]:
675 """
676 Read-only property to access the file the region belongs to (:attr:`_parent`).
678 :returns: The file; ``None`` if the region belongs to no file.
679 """
680 return self._parent
682 @readonly
683 def Name(self) -> str:
684 """
685 Read-only property to access the qualified name of the function or class (:attr:`_name`).
687 :returns: The qualified name.
688 """
689 return self._name
691 @readonly
692 def Kind(self) -> RegionKind:
693 """
694 Read-only property to access the kind of the region (:attr:`_kind`).
696 :returns: The kind: a function or a class.
697 """
698 return self._kind
700 @readonly
701 def StartLine(self) -> int:
702 """
703 Read-only property to access the region's first line (:attr:`_startLine`).
705 :returns: The line number.
706 """
707 return self._startLine
710@export
711class File(Base):
712 """
713 A measured file: its lines, branches and summary, and - in format 3 - its functions and classes.
715 The regions named ``""`` - the lines outside of every function or class - aren't kept: :meth:`Parse` skips them.
716 """
718 _parent: Nullable[Report] #: The report the file belongs to.
719 _path: _Path #: The file's path, relative to the directory coverage.py ran in.
720 _functions: dict[str, Region] #: The functions, by qualified name.
721 _classes: dict[str, Region] #: The classes, by qualified name.
723 def __init__(
724 self,
725 path: Path,
726 summary: Summary,
727 executedLines: Nullable[Iterable[int]] = None,
728 missingLines: Nullable[Iterable[int]] = None,
729 excludedLines: Nullable[Iterable[int]] = None,
730 executedBranches: Nullable[Iterable[tuple[int, int]]] = None,
731 missingBranches: Nullable[Iterable[tuple[int, int]]] = None,
732 *,
733 parent: Nullable[Report] = None
734 ) -> None:
735 """
736 Initialize the file from its path, its summary, its lines and its branches, and add it to the files of its report.
738 Its functions and classes are added by creating them with this file as their parent.
740 :param path: The file's path, relative to the directory coverage.py ran in.
741 :param summary: The counters coverage.py computed.
742 :param executedLines: Optional, the executed lines. Default: ``None`` (none).
743 :param missingLines: Optional, the lines, which never ran. Default: ``None`` (none).
744 :param excludedLines: Optional, the excluded lines. Default: ``None`` (none).
745 :param executedBranches: Optional, the taken branches, as pairs of source and destination line. |br|
746 Default: ``None`` (none, or branch coverage wasn't measured).
747 :param missingBranches: Optional, the branches never taken, as pairs of source and destination line. |br|
748 Default: ``None`` (none, or branch coverage wasn't measured).
749 :param parent: Optional, the report the file belongs to; the file is added to its files by :attr:`Path`.
750 Default: ``None``.
751 :raises ValueError: If parameter ``path`` is ``None``.
752 :raises TypeError: If parameter ``path`` is not of type :class:`~pathlib.Path`.
753 :raises TypeError: If parameter ``parent`` is not of type :class:`Report`.
754 :raises ValueError: If parameter ``parent`` contains a file of the same path already.
755 """
756 super().__init__(summary, executedLines, missingLines, excludedLines, executedBranches, missingBranches)
758 if path is None: 758 ↛ 759line 758 didn't jump to line 759 because the condition on line 758 was never true
759 raise ValueError("Parameter 'path' is None.")
760 elif not isinstance(path, Path):
761 ex = TypeError("Parameter 'path' is not of type 'Path'.")
762 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
763 raise ex
765 if parent is not None:
766 if not isinstance(parent, Report):
767 ex = TypeError("Parameter 'parent' is not of type 'Report'.")
768 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
769 raise ex
770 elif path in parent._files:
771 raise ValueError(f"Parameter 'parent' contains file '{path.as_posix()}' already.")
773 self._parent = parent
774 self._path = path
775 self._functions = {}
776 self._classes = {}
778 if parent is not None:
779 parent._files[path] = self
781 @classmethod
782 def Parse(cls, name: str, file: dict[str, Any], *, parent: Nullable[Report] = None) -> Self:
783 """
784 Read a file, its functions and classes from its JSON object.
786 The regions named ``""`` - the lines outside of every function or class - are skipped.
788 :param name: The file's path as the report states it - its key in ``files`` -, relative to the
789 directory coverage.py ran in; ``\\`` separators become ``/``.
790 :param file: The JSON object of the file.
791 :param parent: Optional, the report the file belongs to. Default: ``None``.
792 :returns: The file.
793 :raises ValueError: If parameter ``name`` is ``None``.
794 :raises TypeError: If parameter ``name`` is not of type :class:`str`.
795 :raises ValueError: If parameter ``file`` is ``None``.
796 :raises TypeError: If parameter ``file`` is not of type :class:`dict`.
797 :raises CodeCoverageError: If the report names the file's path twice.
798 """
799 if name is None:
800 raise ValueError("Parameter 'name' is None.")
801 elif not isinstance(name, str): 801 ↛ 802line 801 didn't jump to line 802 because the condition on line 801 was never true
802 ex = TypeError("Parameter 'name' is not of type 'str'.")
803 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
804 raise ex
806 if file is None: 806 ↛ 807line 806 didn't jump to line 807 because the condition on line 806 was never true
807 raise ValueError("Parameter 'file' is None.")
808 elif not isinstance(file, dict): 808 ↛ 809line 808 didn't jump to line 809 because the condition on line 808 was never true
809 ex = TypeError("Parameter 'file' is not of type 'dict'.")
810 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.")
811 raise ex
813 path = Path(name.replace("\\", "/"))
814 if parent is not None and path in parent._files:
815 raise CodeCoverageError(f"coverage.py report names file '{path.as_posix()}' twice.")
817 measuredFile = cls(
818 path,
819 Summary.Parse(file["summary"]),
820 file["executed_lines"],
821 file["missing_lines"],
822 file["excluded_lines"],
823 (tuple(branch) for branch in file.get("executed_branches", [])),
824 (tuple(branch) for branch in file.get("missing_branches", [])),
825 parent=parent
826 )
828 for kind, regions in ((RegionKind.Function, "functions"), (RegionKind.Class, "classes")):
829 for regionName, region in file.get(regions, {}).items():
830 if regionName != "":
831 Region.Parse(regionName, kind, region, parent=measuredFile)
833 return measuredFile
835 @readonly
836 def Parent(self) -> Nullable[Report]:
837 """
838 Read-only property to access the report the file belongs to (:attr:`_parent`).
840 :returns: The report; ``None`` if the file belongs to no report.
841 """
842 return self._parent
844 @readonly
845 def Path(self) -> Path:
846 """
847 Read-only property to access the file's path (:attr:`_path`).
849 :returns: The path, relative to the directory coverage.py ran in.
850 """
851 return self._path
853 @readonly
854 def Functions(self) -> dict[str, Region]:
855 """
856 Read-only property to access the functions (:attr:`_functions`).
858 :returns: The functions, by qualified name; empty in format 2.
859 """
860 return self._functions
862 @readonly
863 def Classes(self) -> dict[str, Region]:
864 """
865 Read-only property to access the classes (:attr:`_classes`).
867 :returns: The classes, by qualified name; empty in format 2.
868 """
869 return self._classes
872@export
873class Report(metaclass=ExtendedType, mixin=True):
874 """
875 The report's root: how and when it was written, the measured files, and the totals.
876 """
878 _format: Nullable[FormatVersion] #: Version of the report format.
879 _version: Nullable[SemanticVersion] #: Version of coverage.py.
880 _timestamp: Nullable[datetime] #: Time the report was written, local time without time zone.
881 _branchCoverage: bool #: Whether branch coverage was measured.
882 _hasContexts: bool #: Whether the report lists the contexts of the lines.
883 _files: dict[Path, File] #: The measured files, by path.
884 _totals: Nullable[Summary] #: The counters of the whole report.
886 def __init__(self) -> None:
887 """
888 Initialize an empty report.
889 """
890 self._format = None
891 self._version = None
892 self._timestamp = None
893 self._branchCoverage = False
894 self._hasContexts = False
895 self._files = {}
896 self._totals = None
898 @readonly
899 def Format(self) -> Nullable[FormatVersion]:
900 """
901 Read-only property to access the version of the report format (:attr:`_format`).
903 :returns: The version; ``None`` before the report was converted.
904 """
905 return self._format
907 @readonly
908 def Version(self) -> Nullable[SemanticVersion]:
909 """
910 Read-only property to access the version of coverage.py, which wrote the report (:attr:`_version`).
912 The report states it as ``meta.version``, e.g. ``7.16.1``.
914 :returns: The version; ``None`` before the report was converted.
915 """
916 return self._version
918 @readonly
919 def Timestamp(self) -> Nullable[datetime]:
920 """
921 Read-only property to access the time the report was written (:attr:`_timestamp`).
923 :returns: The time in the writer's local time, without a time zone; ``None`` before the report was converted.
924 """
925 return self._timestamp
927 @readonly
928 def BranchCoverage(self) -> bool:
929 """
930 Read-only property to access whether branch coverage was measured (:attr:`_branchCoverage`).
932 :returns: ``True``, if branch coverage was measured.
933 """
934 return self._branchCoverage
936 @readonly
937 def HasContexts(self) -> bool:
938 """
939 Read-only property to access whether the report lists the contexts of the lines (:attr:`_hasContexts`).
941 coverage.py includes them, when configured with ``[json] show_contexts = True``; the report states it as
942 ``show_contexts``.
944 :returns: ``True``, if the files list the contexts of their lines.
945 """
946 return self._hasContexts
948 @readonly
949 def Files(self) -> dict[Path, File]:
950 """
951 Read-only property to access the measured files (:attr:`_files`).
953 :returns: The files, by path.
954 """
955 return self._files
957 @readonly
958 def Totals(self) -> Nullable[Summary]:
959 """
960 Read-only property to access the counters of the whole report (:attr:`_totals`).
962 :returns: The totals; ``None`` before the report was converted.
963 """
964 return self._totals
967@export
968class Document(cc_Document, Report):
969 """
970 A coverage.py JSON code coverage report: read into the format's model, and converted to the common model.
971 """
973 _jsonDocument: Nullable[dict[str, Any]] #: The parsed and validated JSON document, after :meth:`Analyze`.
975 def __init__(self, jsonReportFile: Path, analyzeAndConvert: bool = False) -> None:
976 """
977 Initialize the report, and optionally read it.
979 :param jsonReportFile: Path to the JSON file.
980 :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
981 """
982 super().__init__(jsonReportFile)
983 Report.__init__(self)
985 self._jsonDocument = None
987 if analyzeAndConvert:
988 self.Analyze()
989 self.Convert()
991 def Analyze(self) -> None:
992 """
993 Parse the JSON file, read the format version it states, and validate it against the JSON Schema of that version
994 (:data:`SCHEMAS`).
996 :raises CodeCoverageError: If the file doesn't exist.
997 :raises CodeCoverageError: If the file can't be read.
998 :raises CodeCoverageError: If the file isn't UTF-8 encoded.
999 :raises CodeCoverageError: If the file isn't valid JSON.
1000 :raises CodeCoverageError: If the file states no supported format version. |br|
1001 The note lists the supported format versions.
1002 :raises CodeCoverageError: If the JSON Schema can't be read.
1003 :raises CodeCoverageError: If the file isn't valid according to the JSON Schema of its format version.
1004 """
1005 if not self._path.exists():
1006 raise CodeCoverageError(f"coverage.py report file '{self._path}' does not exist.") \
1007 from FileNotFoundError(f"File '{self._path}' not found.")
1009 from jsonschema import Draft202012Validator
1011 with Stopwatch() as sw:
1012 try:
1013 content = self._path.read_text(encoding="utf-8")
1014 except OSError as ex:
1015 raise CodeCoverageError(f"Couldn't read coverage.py report file '{self._path}'.") from ex
1016 except UnicodeDecodeError as ex:
1017 raise CodeCoverageError(f"coverage.py report file '{self._path}' is not UTF-8 encoded.") from ex
1019 try:
1020 jsonDocument = loads(content)
1021 except JSONDecodeError as ex:
1022 raise CodeCoverageError(f"JSON syntax error in coverage.py report file '{self._path}'.") from ex
1024 meta = jsonDocument.get("meta") if isinstance(jsonDocument, dict) else None
1025 version = meta.get("format") if isinstance(meta, dict) else None
1026 try:
1027 formatVersion = FormatVersion.Parse(version)
1028 except (ValueError, TypeError) as cause:
1029 ex = CodeCoverageError(f"coverage.py report file '{self._path}' states an unsupported format version.")
1030 got = f"value '{version}'" if version is not None else "no value"
1031 ex.add_note(f"Got {got} at '/meta/format'.")
1032 ex.add_note(f"Supported format versions: {', '.join(str(member.value) for member in FormatVersion)}.")
1033 raise ex from cause
1035 schemaFile = SCHEMAS[formatVersion]
1036 try:
1037 schema = loads(readResourceFile(Resources, schemaFile))
1038 except (ToolingException, JSONDecodeError) as ex:
1039 raise CodeCoverageError(f"Couldn't read JSON Schema '{schemaFile}' from package resources.") from ex
1041 errors = sorted(Draft202012Validator(schema).iter_errors(jsonDocument), key=lambda error: list(error.path))
1042 if len(errors) > 0:
1043 ex = CodeCoverageError(f"Validation error for '{self._path}' using JSON Schema '{schemaFile}'.")
1044 for error in errors:
1045 ex.add_note(f"/{'/'.join(str(part) for part in error.path)}: {error.message}")
1046 raise ex
1048 self._jsonDocument = jsonDocument
1050 self._analysisDuration = sw.Duration
1052 def Convert(self) -> None:
1053 """
1054 Convert the parsed and validated JSON document to the format's model.
1056 :raises CodeCoverageError: If the JSON file was not analyzed before. |br|
1057 Call 'Document.Analyze()' or create the document using
1058 'Document(path, analyzeAndConvert=True)'.
1059 :raises CodeCoverageError: If the coverage.py version isn't a semantic version.
1060 :raises CodeCoverageError: If the timestamp isn't in ISO 8601 format.
1061 :raises CodeCoverageError: If the report names a file's path twice.
1062 """
1063 if self._jsonDocument is None:
1064 ex = CodeCoverageError(f"coverage.py report file '{self._path}' needs to be read and analyzed by a JSON parser.")
1065 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
1066 raise ex
1068 with Stopwatch() as sw:
1069 meta = self._jsonDocument["meta"]
1070 try:
1071 version = SemanticVersion.Parse(meta["version"])
1072 except ValueError as cause:
1073 ex = CodeCoverageError(f"coverage.py report file '{self._path}' states a malformed coverage.py version.")
1074 ex.add_note(f"Got value '{meta['version']}' at '/meta/version'.")
1075 raise ex from cause
1077 try:
1078 timestamp = datetime.fromisoformat(meta["timestamp"])
1079 except ValueError as cause:
1080 ex = CodeCoverageError(f"coverage.py report file '{self._path}' states a malformed timestamp.")
1081 ex.add_note(f"Got value '{meta['timestamp']}' at '/meta/timestamp'.")
1082 raise ex from cause
1084 self._format = FormatVersion(meta["format"])
1085 self._version = version
1086 self._timestamp = timestamp
1087 self._branchCoverage = meta["branch_coverage"]
1088 self._hasContexts = meta["show_contexts"]
1089 self._totals = Summary.Parse(self._jsonDocument["totals"])
1091 for name, record in self._jsonDocument["files"].items():
1092 File.Parse(name, record, parent=self)
1094 self._conversionDuration = sw.Duration
1096 def ToCoverageSummary(self) -> CoverageSummary:
1097 """
1098 Convert the format's model to the common model, and aggregate it.
1100 :returns: The report's root of the common model, named after the report file.
1101 :raises CodeCoverageError: If a file's path runs through another file.
1102 """
1103 summary = CoverageSummary(self._path.stem)
1105 for file in self._files.values():
1106 commonFile = summary.GetOrAddFile(file._path)
1107 self._ConvertLines(file, commonFile)
1108 self._ConvertUnits(file, commonFile, summary)
1110 summary.Aggregate()
1111 return summary
1113 @staticmethod
1114 def _ConvertLines(file: File, commonFile: cc_File) -> None:
1115 """
1116 Convert a file's lines and branches to lines of the common model.
1118 :param file: The file of the format's model.
1119 :param commonFile: The file of the common model.
1120 """
1121 missingSources = {source for source, _ in file._missingBranches}
1123 statuses: dict[int, LineCoverageStatus] = {}
1124 for number in file._executedLines:
1125 partial = number in missingSources
1126 statuses[number] = LineCoverageStatus.PartiallyCovered if partial else LineCoverageStatus.Covered
1128 for number in file._missingLines:
1129 statuses[number] = LineCoverageStatus.Uncovered
1131 for number in file._excludedLines:
1132 statuses[number] = LineCoverageStatus.Excluded
1134 for number in sorted(statuses):
1135 cc_Line(number, statuses[number], parent=commonFile)
1137 lines = commonFile._lines
1138 lastLineNumber = commonFile._lastLineNumber
1139 for status, arcs in (
1140 (LineCoverageStatus.Covered, file._executedBranches),
1141 (LineCoverageStatus.Uncovered, file._missingBranches)
1142 ):
1143 for source, target in arcs:
1144 cc_Branch(status, target=lines[target] if 0 < target <= lastLineNumber else None, parent=lines[source])
1146 @staticmethod
1147 def _ConvertUnits(file: File, commonFile: cc_File, summary: CoverageSummary) -> None:
1148 """
1149 Convert a file to units: its directories to packages, the file to a module, its classes and functions to classes,
1150 methods and functions.
1152 :param file: The file of the format's model.
1153 :param commonFile: The file of the common model.
1154 :param summary: The report's root of the common model.
1155 """
1156 path = file._path
1157 parent: cc_Unit | CoverageSummary = summary
1158 for part in path.parent.parts:
1159 parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent)
1161 lines = commonFile._lines
1162 lastLineNumber = commonFile._lastLineNumber
1163 module = cc_Module(
1164 path.stem,
1165 file=commonFile,
1166 startLine=next(commonFile.IterateLines(), None),
1167 endLine=lines[lastLineNumber] if lastLineNumber > 0 else None,
1168 parent=parent
1169 )
1171 for kind, regions in ((RegionKind.Class, file._classes), (RegionKind.Function, file._functions)):
1172 for name in sorted(regions):
1173 region = regions[name]
1174 *outerNames, ownName = name.split(".")
1176 # the enclosing class or function, found by the outer names; a region outside of a known one is left out
1177 container: Nullable[cc_Unit] = module
1178 for outerName in outerNames:
1179 if (container := container._units.get(outerName)) is None: 1179 ↛ 1180line 1179 didn't jump to line 1180 because the condition on line 1179 was never true
1180 break
1182 if container is None or ownName in container._units: 1182 ↛ 1183line 1182 didn't jump to line 1183 because the condition on line 1182 was never true
1183 continue
1185 if kind is RegionKind.Class:
1186 unitClass: type[cc_Unit] = cc_Class
1187 elif isinstance(container, cc_Class):
1188 unitClass = cc_Method
1189 else:
1190 unitClass = cc_Function
1192 numbers = [
1193 number for number in (region._startLine, *region.AllLines)
1194 if number <= lastLineNumber and lines[number] is not None
1195 ]
1197 unitClass(
1198 ownName,
1199 file=commonFile,
1200 startLine=lines[min(numbers)] if len(numbers) > 0 else None,
1201 endLine=lines[max(numbers)] if len(numbers) > 0 else None,
1202 parent=container
1203 )