Coverage for pyEDAA/Reports/CodeCoverage/CoveragePy.py: 90%
492 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
1# ==================================================================================================================== #
2# _____ ____ _ _ ____ _ #
3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
7# |_| |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2021-2026 Electronic Design Automation Abstraction (EDA²) #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
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 jsonschema import Draft202012Validator
73from pyTooling.Common import getFullyQualifiedName, readResourceFile
74from pyTooling.Decorators import export, readonly
75from pyTooling.Exceptions import ToolingException
76from pyTooling.MetaClasses import ExtendedType, abstractclass
77from pyTooling.Stopwatch import Stopwatch
78from pyTooling.Versioning import SemanticVersion
80from pyEDAA.Reports import Resources
81from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, Class as cc_Class, CodeCoverageError, CoverageSummary
82from pyEDAA.Reports.CodeCoverage import Document as cc_Document, File as cc_File, Function as cc_Function
83from pyEDAA.Reports.CodeCoverage import Line as cc_Line, LineCoverageStatus, Method as cc_Method, Module as cc_Module
84from pyEDAA.Reports.CodeCoverage import Package as cc_Package, Unit as cc_Unit
87__all__ = ["SCHEMAS"]
89# A class with a property named like a class - ``Path``, ``Summary`` - can't name that class in the annotation of a
90# field: the class body's namespace, where annotations are evaluated, binds the name to the property.
91_Path = Path
94@export
95class FormatVersion(IntEnum):
96 """
97 Version of coverage.py's JSON report format, as a report's ``meta.format`` states it.
99 coverage.py 7.4.1 added ``meta.format`` with format 2. coverage.py 7.6 and later write format 3, which adds the
100 functions and classes of a file. A report of an earlier coverage.py states no format version and isn't supported.
101 """
103 Version2 = 2 #: Format 2, written by coverage.py 7.4.1 to 7.5.
104 Version3 = 3 #: Format 3, written by coverage.py 7.6 and later.
106 @classmethod
107 def Parse(cls, value: int) -> Self:
108 """
109 Convert the version, as a report's ``meta.format`` states it, to the member of that version.
111 :param value: The version.
112 :returns: The member of that version.
113 :raises ValueError: If parameter ``value`` is ``None``.
114 :raises TypeError: If parameter ``value`` is not of type :class:`int`.
115 :raises ValueError: If parameter ``value`` is not a supported format version.
116 """
117 if value is None:
118 raise ValueError("Parameter 'value' is None.")
119 elif not isinstance(value, int) or isinstance(value, bool):
120 ex = TypeError("Parameter 'value' is not of type 'int'.")
121 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
122 raise ex
123 elif value not in [member.value for member in cls]:
124 ex = ValueError("Parameter 'value' is not a supported coverage.py JSON format version.")
125 ex.add_note(f"Got value '{value}'.")
126 ex.add_note(f"Supported format versions: {', '.join(str(member.value) for member in cls)}.")
127 raise ex
129 return cls(value)
132SCHEMAS: dict[FormatVersion, str] = {
133 FormatVersion.Version2: "CoveragePy-2.schema.json",
134 FormatVersion.Version3: "CoveragePy-3.schema.json"
135} #: Per format version, the JSON Schema a report of that version is validated against.
138@export
139class Summary(metaclass=ExtendedType, slots=True):
140 """
141 A ``summary``: the counters coverage.py computed for the whole report, a file or a region.
143 coverage.py measures lines. What it calls a statement (``num_statements``) is a line number: a statement spanning
144 several lines counts by its first line, several statements on one line count once.
145 """
147 _lineCount: int #: Number of executable lines, without the excluded ones.
148 _coveredLineCount: int #: Number of executed lines.
149 _missingLineCount: int #: Number of executable lines, which never ran.
150 _excludedLineCount: int #: Number of excluded lines.
151 _percentCovered: float #: Coverage of lines and branches, in percent.
152 _branchCount: Nullable[int] #: Number of branches, if branch coverage was measured.
153 _coveredBranchCount: Nullable[int] #: Number of taken branches, if branch coverage was measured.
154 _partialBranchCount: Nullable[int] #: Number of branches never taken from executed lines, if branches were measured.
156 def __init__(
157 self,
158 lineCount: int,
159 coveredLineCount: int,
160 missingLineCount: int,
161 excludedLineCount: int,
162 percentCovered: float,
163 branchCount: Nullable[int] = None,
164 coveredBranchCount: Nullable[int] = None,
165 partialBranchCount: Nullable[int] = None
166 ) -> None:
167 """
168 Initialize the summary from its counters.
170 :param lineCount: Number of executable lines, without the excluded ones.
171 :param coveredLineCount: Number of executed lines.
172 :param missingLineCount: Number of executable lines, which never ran.
173 :param excludedLineCount: Number of excluded lines.
174 :param percentCovered: Coverage of lines and branches, in percent.
175 :param branchCount: Optional, number of branches. Default: ``None`` (branch coverage wasn't measured).
176 :param coveredBranchCount: Optional, number of taken branches. Default: ``None`` (branch coverage wasn't measured).
177 :param partialBranchCount: Optional, number of branches never taken from executed lines. |br|
178 Default: ``None`` (branch coverage wasn't measured).
179 :raises ValueError: If parameter ``lineCount`` is ``None``.
180 :raises TypeError: If parameter ``lineCount`` is not of type :class:`int`.
181 :raises ValueError: If parameter ``lineCount`` is negative.
182 :raises ValueError: If parameter ``coveredLineCount`` is ``None``.
183 :raises TypeError: If parameter ``coveredLineCount`` is not of type :class:`int`.
184 :raises ValueError: If parameter ``coveredLineCount`` is negative.
185 :raises ValueError: If parameter ``missingLineCount`` is ``None``.
186 :raises TypeError: If parameter ``missingLineCount`` is not of type :class:`int`.
187 :raises ValueError: If parameter ``missingLineCount`` is negative.
188 :raises ValueError: If parameter ``excludedLineCount`` is ``None``.
189 :raises TypeError: If parameter ``excludedLineCount`` is not of type :class:`int`.
190 :raises ValueError: If parameter ``excludedLineCount`` is negative.
191 :raises ValueError: If parameter ``percentCovered`` is ``None``.
192 :raises TypeError: If parameter ``percentCovered`` is not of type :class:`float`.
193 :raises ValueError: If parameter ``percentCovered`` is out of range 0..100.
194 :raises TypeError: If parameter ``branchCount`` is not of type :class:`int`.
195 :raises ValueError: If parameter ``branchCount`` is negative.
196 :raises TypeError: If parameter ``coveredBranchCount`` is not of type :class:`int`.
197 :raises ValueError: If parameter ``coveredBranchCount`` is negative.
198 :raises TypeError: If parameter ``partialBranchCount`` is not of type :class:`int`.
199 :raises ValueError: If parameter ``partialBranchCount`` is negative.
200 """
201 for name, count in (
202 ("lineCount", lineCount),
203 ("coveredLineCount", coveredLineCount),
204 ("missingLineCount", missingLineCount),
205 ("excludedLineCount", excludedLineCount)
206 ):
207 if count is None:
208 raise ValueError(f"Parameter '{name}' is None.")
209 elif not isinstance(count, int):
210 ex = TypeError(f"Parameter '{name}' is not of type 'int'.")
211 ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.")
212 raise ex
213 elif count < 0: 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true
214 ex = ValueError(f"Parameter '{name}' is negative.")
215 ex.add_note(f"Got value '{count}'.")
216 raise ex
218 if percentCovered is None: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 raise ValueError("Parameter 'percentCovered' is None.")
220 elif not isinstance(percentCovered, (int, float)): 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 ex = TypeError("Parameter 'percentCovered' is not of type 'float'.")
222 ex.add_note(f"Got type '{getFullyQualifiedName(percentCovered)}'.")
223 raise ex
224 elif not 0 <= percentCovered <= 100:
225 ex = ValueError("Parameter 'percentCovered' is out of range 0..100.")
226 ex.add_note(f"Got value '{percentCovered}'.")
227 raise ex
229 for name, count in (
230 ("branchCount", branchCount),
231 ("coveredBranchCount", coveredBranchCount),
232 ("partialBranchCount", partialBranchCount)
233 ):
234 if count is None:
235 continue
236 elif not isinstance(count, int): 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true
237 ex = TypeError(f"Parameter '{name}' is not of type 'int'.")
238 ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.")
239 raise ex
240 elif count < 0:
241 ex = ValueError(f"Parameter '{name}' is negative.")
242 ex.add_note(f"Got value '{count}'.")
243 raise ex
245 self._lineCount = lineCount
246 self._coveredLineCount = coveredLineCount
247 self._missingLineCount = missingLineCount
248 self._excludedLineCount = excludedLineCount
249 self._percentCovered = percentCovered
250 self._branchCount = branchCount
251 self._coveredBranchCount = coveredBranchCount
252 self._partialBranchCount = partialBranchCount
254 @classmethod
255 def Parse(cls, summary: dict[str, Any]) -> Self:
256 """
257 Read a summary from its JSON object.
259 :param summary: The JSON object ``summary`` or ``totals``.
260 :returns: The summary.
261 :raises ValueError: If parameter ``summary`` is ``None``.
262 :raises TypeError: If parameter ``summary`` is not of type :class:`dict`.
263 """
264 if summary is None:
265 raise ValueError("Parameter 'summary' is None.")
266 elif not isinstance(summary, dict): 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 ex = TypeError("Parameter 'summary' is not of type 'dict'.")
268 ex.add_note(f"Got type '{getFullyQualifiedName(summary)}'.")
269 raise ex
271 return cls(
272 summary["num_statements"],
273 summary["covered_lines"],
274 summary["missing_lines"],
275 summary["excluded_lines"],
276 summary["percent_covered"],
277 summary.get("num_branches"),
278 summary.get("covered_branches"),
279 summary.get("num_partial_branches")
280 )
282 @readonly
283 def LineCount(self) -> int:
284 """
285 Read-only property to access the number of executable lines, without the excluded ones (:attr:`_lineCount`).
287 coverage.py states it as ``num_statements``.
289 :returns: The number of executable lines.
290 """
291 return self._lineCount
293 @readonly
294 def CoveredLineCount(self) -> int:
295 """
296 Read-only property to access the number of executed lines (:attr:`_coveredLineCount`).
298 :returns: The number of executed lines.
299 """
300 return self._coveredLineCount
302 @readonly
303 def MissingLineCount(self) -> int:
304 """
305 Read-only property to access the number of executable lines, which never ran (:attr:`_missingLineCount`).
307 :returns: The number of missing lines.
308 """
309 return self._missingLineCount
311 @readonly
312 def ExcludedLineCount(self) -> int:
313 """
314 Read-only property to access the number of excluded lines (:attr:`_excludedLineCount`).
316 :returns: The number of excluded lines.
317 """
318 return self._excludedLineCount
320 @readonly
321 def PercentCovered(self) -> float:
322 """
323 Read-only property to access the coverage of lines and branches (:attr:`_percentCovered`).
325 :returns: The coverage in percent.
326 """
327 return self._percentCovered
329 @readonly
330 def BranchCount(self) -> Nullable[int]:
331 """
332 Read-only property to access the number of branches (:attr:`_branchCount`).
334 :returns: The number of branches, or ``None`` if branch coverage wasn't measured.
335 """
336 return self._branchCount
338 @readonly
339 def CoveredBranchCount(self) -> Nullable[int]:
340 """
341 Read-only property to access the number of taken branches (:attr:`_coveredBranchCount`).
343 :returns: The number of taken branches, or ``None`` if branch coverage wasn't measured.
344 """
345 return self._coveredBranchCount
347 @readonly
348 def PartialBranchCount(self) -> Nullable[int]:
349 """
350 Read-only property to access the number of branches never taken from executed lines (:attr:`_partialBranchCount`).
352 :returns: The number of branches, which were never taken although their line ran, or ``None`` if branch coverage
353 wasn't measured.
354 """
355 return self._partialBranchCount
358_Summary = Summary
361@export
362@abstractclass
363class Base(metaclass=ExtendedType, slots=True):
364 """
365 Base-class of a file and a region: its executed, missing and excluded lines, its branches, and its summary.
366 """
368 _executedLines: list[int] #: The executed lines.
369 _missingLines: list[int] #: The lines, which never ran.
370 _excludedLines: list[int] #: The excluded lines.
371 _executedBranches: list[tuple[int, int]] #: The taken branches, as pairs of source and destination line.
372 _missingBranches: list[tuple[int, int]] #: The branches never taken, as pairs of source and destination line.
373 _summary: _Summary #: The counters coverage.py computed.
375 def __init__(
376 self,
377 summary: Summary,
378 executedLines: Nullable[Iterable[int]] = None,
379 missingLines: Nullable[Iterable[int]] = None,
380 excludedLines: Nullable[Iterable[int]] = None,
381 executedBranches: Nullable[Iterable[tuple[int, int]]] = None,
382 missingBranches: Nullable[Iterable[tuple[int, int]]] = None
383 ) -> None:
384 """
385 Initialize the summary, the lines and the branches.
387 :param summary: The counters coverage.py computed.
388 :param executedLines: Optional, the executed lines. Default: ``None`` (none).
389 :param missingLines: Optional, the lines, which never ran. Default: ``None`` (none).
390 :param excludedLines: Optional, the excluded lines. Default: ``None`` (none).
391 :param executedBranches: Optional, the taken branches, as pairs of source and destination line. |br|
392 Default: ``None`` (none, or branch coverage wasn't measured).
393 :param missingBranches: Optional, the branches never taken, as pairs of source and destination line. |br|
394 Default: ``None`` (none, or branch coverage wasn't measured).
395 :raises ValueError: If parameter ``summary`` is ``None``.
396 :raises TypeError: If parameter ``summary`` is not of type :class:`Summary`.
397 :raises TypeError: If parameter ``executedLines`` is not iterable.
398 :raises TypeError: If an element of parameter ``executedLines`` is not of type :class:`int`.
399 :raises TypeError: If parameter ``missingLines`` is not iterable.
400 :raises TypeError: If an element of parameter ``missingLines`` is not of type :class:`int`.
401 :raises TypeError: If parameter ``excludedLines`` is not iterable.
402 :raises TypeError: If an element of parameter ``excludedLines`` is not of type :class:`int`.
403 :raises TypeError: If parameter ``executedBranches`` is not iterable.
404 :raises TypeError: If an element of parameter ``executedBranches`` is not a pair of :class:`int`.
405 :raises TypeError: If parameter ``missingBranches`` is not iterable.
406 :raises TypeError: If an element of parameter ``missingBranches`` is not a pair of :class:`int`.
407 """
408 if summary is None:
409 raise ValueError("Parameter 'summary' is None.")
410 elif not isinstance(summary, Summary): 410 ↛ 411line 410 didn't jump to line 411 because the condition on line 410 was never true
411 ex = TypeError("Parameter 'summary' is not of type 'Summary'.")
412 ex.add_note(f"Got type '{getFullyQualifiedName(summary)}'.")
413 raise ex
415 lineLists: list[list[int]] = []
416 for name, numbers in (
417 ("executedLines", executedLines),
418 ("missingLines", missingLines),
419 ("excludedLines", excludedLines)
420 ):
421 lineList: list[int] = []
422 if numbers is not None:
423 if not isinstance(numbers, Iterable): 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true
424 ex = TypeError(f"Parameter '{name}' is not iterable.")
425 ex.add_note(f"Got type '{getFullyQualifiedName(numbers)}'.")
426 raise ex
428 for number in numbers:
429 if not isinstance(number, int):
430 ex = TypeError(f"An element of parameter '{name}' is not of type 'int'.")
431 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
432 raise ex
434 lineList.append(number)
435 lineLists.append(lineList)
437 branchLists: list[list[tuple[int, int]]] = []
438 for name, branches in (
439 ("executedBranches", executedBranches),
440 ("missingBranches", missingBranches)
441 ):
442 branchList: list[tuple[int, int]] = []
443 if branches is not None:
444 if not isinstance(branches, Iterable): 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true
445 ex = TypeError(f"Parameter '{name}' is not iterable.")
446 ex.add_note(f"Got type '{getFullyQualifiedName(branches)}'.")
447 raise ex
449 for branch in branches:
450 if not (isinstance(branch, tuple) and len(branch) == 2 and all(isinstance(line, int) for line in branch)):
451 ex = TypeError(f"An element of parameter '{name}' is not a pair of 'int'.")
452 ex.add_note(f"Got '{branch!r}' of type '{getFullyQualifiedName(branch)}'.")
453 raise ex
455 branchList.append(branch)
456 branchLists.append(branchList)
458 self._executedLines, self._missingLines, self._excludedLines = lineLists
459 self._executedBranches, self._missingBranches = branchLists
460 self._summary = summary
462 @readonly
463 def ExecutedLines(self) -> list[int]:
464 """
465 Read-only property to access the executed lines (:attr:`_executedLines`).
467 :returns: The line numbers.
468 """
469 return self._executedLines
471 @readonly
472 def MissingLines(self) -> list[int]:
473 """
474 Read-only property to access the lines, which never ran (:attr:`_missingLines`).
476 :returns: The line numbers.
477 """
478 return self._missingLines
480 @readonly
481 def ExcludedLines(self) -> list[int]:
482 """
483 Read-only property to access the excluded lines (:attr:`_excludedLines`).
485 :returns: The line numbers.
486 """
487 return self._excludedLines
489 @readonly
490 def ExecutedBranches(self) -> list[tuple[int, int]]:
491 """
492 Read-only property to access the taken branches (:attr:`_executedBranches`).
494 :returns: The branches as pairs of source and destination line; empty, if branch coverage wasn't measured.
495 """
496 return self._executedBranches
498 @readonly
499 def MissingBranches(self) -> list[tuple[int, int]]:
500 """
501 Read-only property to access the branches never taken (:attr:`_missingBranches`).
503 :returns: The branches as pairs of source and destination line; empty, if branch coverage wasn't measured.
504 """
505 return self._missingBranches
507 @readonly
508 def Summary(self) -> Summary:
509 """
510 Read-only property to access the counters coverage.py computed (:attr:`_summary`).
512 :returns: The summary.
513 """
514 return self._summary
516 @readonly
517 def AllLines(self) -> list[int]:
518 """
519 Read-only property to return the executed, missing and excluded lines.
521 :returns: The line numbers, sorted.
522 """
523 return sorted({*self._executedLines, *self._missingLines, *self._excludedLines})
526@export
527class RegionKind(Enum):
528 """
529 Kind of a region of a file: a function or a class.
531 A file states its regions of each kind in their own JSON object: ``functions`` and ``classes``.
532 """
534 Function = 1 #: A function or a method, listed in ``functions``.
535 Class = 2 #: A class, listed in ``classes``.
538@export
539class Region(Base):
540 """
541 A function or a class of a file - in format 3 -, named by its qualified name.
543 A method ``Area`` of a class ``Circle`` is named ``Circle.Area``.
544 """
546 _parent: Nullable[File] #: The file the region belongs to.
547 _name: str #: Qualified name of the function or class.
548 _kind: RegionKind #: Kind of the region: a function or a class.
549 _startLine: int #: The region's first line.
551 def __init__(
552 self,
553 name: str,
554 kind: RegionKind,
555 startLine: int,
556 summary: Summary,
557 executedLines: Nullable[Iterable[int]] = None,
558 missingLines: Nullable[Iterable[int]] = None,
559 excludedLines: Nullable[Iterable[int]] = None,
560 executedBranches: Nullable[Iterable[tuple[int, int]]] = None,
561 missingBranches: Nullable[Iterable[tuple[int, int]]] = None,
562 *,
563 parent: Nullable[File] = None
564 ) -> None:
565 """
566 Initialize the region from its name, its kind, its first line, its summary, its lines and its branches, and add it
567 to the functions or classes of its file.
569 :param name: Qualified name of the function or class, e.g. ``Circle.Area``.
570 :param kind: Kind of the region: a function or a class.
571 :param startLine: The region's first line.
572 :param summary: The counters coverage.py computed.
573 :param executedLines: Optional, the executed lines. Default: ``None`` (none).
574 :param missingLines: Optional, the lines, which never ran. Default: ``None`` (none).
575 :param excludedLines: Optional, the excluded lines. Default: ``None`` (none).
576 :param executedBranches: Optional, the taken branches, as pairs of source and destination line. |br|
577 Default: ``None`` (none, or branch coverage wasn't measured).
578 :param missingBranches: Optional, the branches never taken, as pairs of source and destination line. |br|
579 Default: ``None`` (none, or branch coverage wasn't measured).
580 :param parent: Optional, the file the region belongs to; the region is added to its functions or classes
581 by :attr:`Name`, as its :attr:`Kind` says. Default: ``None``.
582 :raises ValueError: If parameter ``name`` is ``None``.
583 :raises TypeError: If parameter ``name`` is not of type :class:`str`.
584 :raises ValueError: If parameter ``name`` is empty.
585 :raises ValueError: If parameter ``kind`` is ``None``.
586 :raises TypeError: If parameter ``kind`` is not of type :class:`RegionKind`.
587 :raises ValueError: If parameter ``startLine`` is ``None``.
588 :raises TypeError: If parameter ``startLine`` is not of type :class:`int`.
589 :raises ValueError: If parameter ``startLine`` is less than 1.
590 :raises TypeError: If parameter ``parent`` is not of type :class:`File`.
591 :raises ValueError: If parameter ``parent`` contains a region of the same kind and name already.
592 """
593 super().__init__(summary, executedLines, missingLines, excludedLines, executedBranches, missingBranches)
595 if name is None: 595 ↛ 596line 595 didn't jump to line 596 because the condition on line 595 was never true
596 raise ValueError("Parameter 'name' is None.")
597 elif not isinstance(name, str): 597 ↛ 598line 597 didn't jump to line 598 because the condition on line 597 was never true
598 ex = TypeError("Parameter 'name' is not of type 'str'.")
599 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
600 raise ex
601 elif name == "":
602 raise ValueError("Parameter 'name' is empty.")
604 if kind is None: 604 ↛ 605line 604 didn't jump to line 605 because the condition on line 604 was never true
605 raise ValueError("Parameter 'kind' is None.")
606 elif not isinstance(kind, RegionKind):
607 ex = TypeError("Parameter 'kind' is not of type 'RegionKind'.")
608 ex.add_note(f"Got type '{getFullyQualifiedName(kind)}'.")
609 raise ex
611 if startLine is None: 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true
612 raise ValueError("Parameter 'startLine' is None.")
613 elif not isinstance(startLine, int): 613 ↛ 614line 613 didn't jump to line 614 because the condition on line 613 was never true
614 ex = TypeError("Parameter 'startLine' is not of type 'int'.")
615 ex.add_note(f"Got type '{getFullyQualifiedName(startLine)}'.")
616 raise ex
617 elif startLine < 1:
618 ex = ValueError("Parameter 'startLine' is less than 1.")
619 ex.add_note(f"Got value '{startLine}'.")
620 raise ex
622 if parent is not None:
623 if not isinstance(parent, File):
624 ex = TypeError("Parameter 'parent' is not of type 'File'.")
625 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
626 raise ex
628 regions = parent._functions if kind is RegionKind.Function else parent._classes
629 if name in regions:
630 raise ValueError(f"Parameter 'parent' contains {kind.name.lower()} '{name}' already.")
632 self._parent = parent
633 self._name = name
634 self._kind = kind
635 self._startLine = startLine
637 if parent is not None:
638 regions[name] = self
640 @classmethod
641 def Parse(cls, name: str, kind: RegionKind, region: dict[str, Any], *, parent: Nullable[File] = None) -> Self:
642 """
643 Read a region from its JSON object.
645 :param name: Qualified name of the function or class: the region's key in ``functions`` or ``classes``.
646 :param kind: Kind of the region: :attr:`RegionKind.Function` in ``functions``, :attr:`RegionKind.Class` in
647 ``classes``.
648 :param region: The JSON object of the region.
649 :param parent: Optional, the file the region belongs to. Default: ``None``.
650 :returns: The region.
651 :raises ValueError: If parameter ``region`` is ``None``.
652 :raises TypeError: If parameter ``region`` is not of type :class:`dict`.
653 """
654 if region is None: 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true
655 raise ValueError("Parameter 'region' is None.")
656 elif not isinstance(region, dict):
657 ex = TypeError("Parameter 'region' is not of type 'dict'.")
658 ex.add_note(f"Got type '{getFullyQualifiedName(region)}'.")
659 raise ex
661 return cls(
662 name,
663 kind,
664 region["start_line"],
665 Summary.Parse(region["summary"]),
666 region["executed_lines"],
667 region["missing_lines"],
668 region["excluded_lines"],
669 (tuple(branch) for branch in region.get("executed_branches", [])),
670 (tuple(branch) for branch in region.get("missing_branches", [])),
671 parent=parent
672 )
674 @readonly
675 def Parent(self) -> Nullable[File]:
676 """
677 Read-only property to access the file the region belongs to (:attr:`_parent`).
679 :returns: The file; ``None`` if the region belongs to no file.
680 """
681 return self._parent
683 @readonly
684 def Name(self) -> str:
685 """
686 Read-only property to access the qualified name of the function or class (:attr:`_name`).
688 :returns: The qualified name.
689 """
690 return self._name
692 @readonly
693 def Kind(self) -> RegionKind:
694 """
695 Read-only property to access the kind of the region (:attr:`_kind`).
697 :returns: The kind: a function or a class.
698 """
699 return self._kind
701 @readonly
702 def StartLine(self) -> int:
703 """
704 Read-only property to access the region's first line (:attr:`_startLine`).
706 :returns: The line number.
707 """
708 return self._startLine
711@export
712class File(Base):
713 """
714 A measured file: its lines, branches and summary, and - in format 3 - its functions and classes.
716 The regions named ``""`` - the lines outside of every function or class - aren't kept: :meth:`Parse` skips them.
717 """
719 _parent: Nullable[Report] #: The report the file belongs to.
720 _path: _Path #: The file's path, relative to the directory coverage.py ran in.
721 _functions: dict[str, Region] #: The functions, by qualified name.
722 _classes: dict[str, Region] #: The classes, by qualified name.
724 def __init__(
725 self,
726 path: Path,
727 summary: Summary,
728 executedLines: Nullable[Iterable[int]] = None,
729 missingLines: Nullable[Iterable[int]] = None,
730 excludedLines: Nullable[Iterable[int]] = None,
731 executedBranches: Nullable[Iterable[tuple[int, int]]] = None,
732 missingBranches: Nullable[Iterable[tuple[int, int]]] = None,
733 *,
734 parent: Nullable[Report] = None
735 ) -> None:
736 """
737 Initialize the file from its path, its summary, its lines and its branches, and add it to the files of its report.
739 Its functions and classes are added by creating them with this file as their parent.
741 :param path: The file's path, relative to the directory coverage.py ran in.
742 :param summary: The counters coverage.py computed.
743 :param executedLines: Optional, the executed lines. Default: ``None`` (none).
744 :param missingLines: Optional, the lines, which never ran. Default: ``None`` (none).
745 :param excludedLines: Optional, the excluded lines. Default: ``None`` (none).
746 :param executedBranches: Optional, the taken branches, as pairs of source and destination line. |br|
747 Default: ``None`` (none, or branch coverage wasn't measured).
748 :param missingBranches: Optional, the branches never taken, as pairs of source and destination line. |br|
749 Default: ``None`` (none, or branch coverage wasn't measured).
750 :param parent: Optional, the report the file belongs to; the file is added to its files by :attr:`Path`.
751 Default: ``None``.
752 :raises ValueError: If parameter ``path`` is ``None``.
753 :raises TypeError: If parameter ``path`` is not of type :class:`~pathlib.Path`.
754 :raises TypeError: If parameter ``parent`` is not of type :class:`Report`.
755 :raises ValueError: If parameter ``parent`` contains a file of the same path already.
756 """
757 super().__init__(summary, executedLines, missingLines, excludedLines, executedBranches, missingBranches)
759 if path is None: 759 ↛ 760line 759 didn't jump to line 760 because the condition on line 759 was never true
760 raise ValueError("Parameter 'path' is None.")
761 elif not isinstance(path, Path):
762 ex = TypeError("Parameter 'path' is not of type 'Path'.")
763 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
764 raise ex
766 if parent is not None:
767 if not isinstance(parent, Report):
768 ex = TypeError("Parameter 'parent' is not of type 'Report'.")
769 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
770 raise ex
771 elif path in parent._files:
772 raise ValueError(f"Parameter 'parent' contains file '{path.as_posix()}' already.")
774 self._parent = parent
775 self._path = path
776 self._functions = {}
777 self._classes = {}
779 if parent is not None:
780 parent._files[path] = self
782 @classmethod
783 def Parse(cls, name: str, file: dict[str, Any], *, parent: Nullable[Report] = None) -> Self:
784 """
785 Read a file, its functions and classes from its JSON object.
787 The regions named ``""`` - the lines outside of every function or class - are skipped.
789 :param name: The file's path as the report states it - its key in ``files`` -, relative to the
790 directory coverage.py ran in; ``\\`` separators become ``/``.
791 :param file: The JSON object of the file.
792 :param parent: Optional, the report the file belongs to. Default: ``None``.
793 :returns: The file.
794 :raises ValueError: If parameter ``name`` is ``None``.
795 :raises TypeError: If parameter ``name`` is not of type :class:`str`.
796 :raises ValueError: If parameter ``file`` is ``None``.
797 :raises TypeError: If parameter ``file`` is not of type :class:`dict`.
798 :raises CodeCoverageError: If the report names the file's path twice.
799 """
800 if name is None:
801 raise ValueError("Parameter 'name' is None.")
802 elif not isinstance(name, str): 802 ↛ 803line 802 didn't jump to line 803 because the condition on line 802 was never true
803 ex = TypeError("Parameter 'name' is not of type 'str'.")
804 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
805 raise ex
807 if file is None: 807 ↛ 808line 807 didn't jump to line 808 because the condition on line 807 was never true
808 raise ValueError("Parameter 'file' is None.")
809 elif not isinstance(file, dict): 809 ↛ 810line 809 didn't jump to line 810 because the condition on line 809 was never true
810 ex = TypeError("Parameter 'file' is not of type 'dict'.")
811 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.")
812 raise ex
814 path = Path(name.replace("\\", "/"))
815 if parent is not None and path in parent._files:
816 raise CodeCoverageError(f"coverage.py report names file '{path.as_posix()}' twice.")
818 measuredFile = cls(
819 path,
820 Summary.Parse(file["summary"]),
821 file["executed_lines"],
822 file["missing_lines"],
823 file["excluded_lines"],
824 (tuple(branch) for branch in file.get("executed_branches", [])),
825 (tuple(branch) for branch in file.get("missing_branches", [])),
826 parent=parent
827 )
829 for kind, regions in ((RegionKind.Function, "functions"), (RegionKind.Class, "classes")):
830 for regionName, region in file.get(regions, {}).items():
831 if regionName != "":
832 Region.Parse(regionName, kind, region, parent=measuredFile)
834 return measuredFile
836 @readonly
837 def Parent(self) -> Nullable[Report]:
838 """
839 Read-only property to access the report the file belongs to (:attr:`_parent`).
841 :returns: The report; ``None`` if the file belongs to no report.
842 """
843 return self._parent
845 @readonly
846 def Path(self) -> Path:
847 """
848 Read-only property to access the file's path (:attr:`_path`).
850 :returns: The path, relative to the directory coverage.py ran in.
851 """
852 return self._path
854 @readonly
855 def Functions(self) -> dict[str, Region]:
856 """
857 Read-only property to access the functions (:attr:`_functions`).
859 :returns: The functions, by qualified name; empty in format 2.
860 """
861 return self._functions
863 @readonly
864 def Classes(self) -> dict[str, Region]:
865 """
866 Read-only property to access the classes (:attr:`_classes`).
868 :returns: The classes, by qualified name; empty in format 2.
869 """
870 return self._classes
873@export
874class Report(metaclass=ExtendedType, mixin=True):
875 """
876 The report's root: how and when it was written, the measured files, and the totals.
877 """
879 _format: Nullable[FormatVersion] #: Version of the report format.
880 _version: Nullable[SemanticVersion] #: Version of coverage.py.
881 _timestamp: Nullable[datetime] #: Time the report was written, local time without time zone.
882 _branchCoverage: bool #: Whether branch coverage was measured.
883 _hasContexts: bool #: Whether the report lists the contexts of the lines.
884 _files: dict[Path, File] #: The measured files, by path.
885 _totals: Nullable[Summary] #: The counters of the whole report.
887 def __init__(self) -> None:
888 """
889 Initialize an empty report.
890 """
891 self._format = None
892 self._version = None
893 self._timestamp = None
894 self._branchCoverage = False
895 self._hasContexts = False
896 self._files = {}
897 self._totals = None
899 @readonly
900 def Format(self) -> Nullable[FormatVersion]:
901 """
902 Read-only property to access the version of the report format (:attr:`_format`).
904 :returns: The version; ``None`` before the report was converted.
905 """
906 return self._format
908 @readonly
909 def Version(self) -> Nullable[SemanticVersion]:
910 """
911 Read-only property to access the version of coverage.py, which wrote the report (:attr:`_version`).
913 The report states it as ``meta.version``, e.g. ``7.16.1``.
915 :returns: The version; ``None`` before the report was converted.
916 """
917 return self._version
919 @readonly
920 def Timestamp(self) -> Nullable[datetime]:
921 """
922 Read-only property to access the time the report was written (:attr:`_timestamp`).
924 :returns: The time in the writer's local time, without a time zone; ``None`` before the report was converted.
925 """
926 return self._timestamp
928 @readonly
929 def BranchCoverage(self) -> bool:
930 """
931 Read-only property to access whether branch coverage was measured (:attr:`_branchCoverage`).
933 :returns: ``True``, if branch coverage was measured.
934 """
935 return self._branchCoverage
937 @readonly
938 def HasContexts(self) -> bool:
939 """
940 Read-only property to access whether the report lists the contexts of the lines (:attr:`_hasContexts`).
942 coverage.py includes them, when configured with ``[json] show_contexts = True``; the report states it as
943 ``show_contexts``.
945 :returns: ``True``, if the files list the contexts of their lines.
946 """
947 return self._hasContexts
949 @readonly
950 def Files(self) -> dict[Path, File]:
951 """
952 Read-only property to access the measured files (:attr:`_files`).
954 :returns: The files, by path.
955 """
956 return self._files
958 @readonly
959 def Totals(self) -> Nullable[Summary]:
960 """
961 Read-only property to access the counters of the whole report (:attr:`_totals`).
963 :returns: The totals; ``None`` before the report was converted.
964 """
965 return self._totals
968@export
969class Document(cc_Document, Report):
970 """
971 A coverage.py JSON code coverage report: read into the format's model, and converted to the common model.
972 """
974 _jsonDocument: Nullable[dict[str, Any]] #: The parsed and validated JSON document, after :meth:`Analyze`.
976 def __init__(self, jsonReportFile: Path, analyzeAndConvert: bool = False) -> None:
977 """
978 Initialize the report, and optionally read it.
980 :param jsonReportFile: Path to the JSON file.
981 :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
982 """
983 super().__init__(jsonReportFile)
984 Report.__init__(self)
986 self._jsonDocument = None
988 if analyzeAndConvert:
989 self.Analyze()
990 self.Convert()
992 def Analyze(self) -> None:
993 """
994 Parse the JSON file, read the format version it states, and validate it against the JSON Schema of that version
995 (:data:`SCHEMAS`).
997 :raises CodeCoverageError: If the file doesn't exist.
998 :raises CodeCoverageError: If the file can't be read.
999 :raises CodeCoverageError: If the file isn't UTF-8 encoded.
1000 :raises CodeCoverageError: If the file isn't valid JSON.
1001 :raises CodeCoverageError: If the file states no supported format version. |br|
1002 The note lists the supported format versions.
1003 :raises CodeCoverageError: If the JSON Schema can't be read.
1004 :raises CodeCoverageError: If the file isn't valid according to the JSON Schema of its format version.
1005 """
1006 if not self._path.exists():
1007 raise CodeCoverageError(f"coverage.py report file '{self._path}' does not exist.") \
1008 from FileNotFoundError(f"File '{self._path}' not found.")
1010 with Stopwatch() as sw:
1011 try:
1012 content = self._path.read_text(encoding="utf-8")
1013 except OSError as ex:
1014 raise CodeCoverageError(f"Couldn't read coverage.py report file '{self._path}'.") from ex
1015 except UnicodeDecodeError as ex:
1016 raise CodeCoverageError(f"coverage.py report file '{self._path}' is not UTF-8 encoded.") from ex
1018 try:
1019 jsonDocument = loads(content)
1020 except JSONDecodeError as ex:
1021 raise CodeCoverageError(f"JSON syntax error in coverage.py report file '{self._path}'.") from ex
1023 meta = jsonDocument.get("meta") if isinstance(jsonDocument, dict) else None
1024 version = meta.get("format") if isinstance(meta, dict) else None
1025 try:
1026 formatVersion = FormatVersion.Parse(version)
1027 except (ValueError, TypeError) as cause:
1028 ex = CodeCoverageError(f"coverage.py report file '{self._path}' states an unsupported format version.")
1029 got = f"value '{version}'" if version is not None else "no value"
1030 ex.add_note(f"Got {got} at '/meta/format'.")
1031 ex.add_note(f"Supported format versions: {', '.join(str(member.value) for member in FormatVersion)}.")
1032 raise ex from cause
1034 schemaFile = SCHEMAS[formatVersion]
1035 try:
1036 schema = loads(readResourceFile(Resources, schemaFile))
1037 except (ToolingException, JSONDecodeError) as ex:
1038 raise CodeCoverageError(f"Couldn't read JSON Schema '{schemaFile}' from package resources.") from ex
1040 errors = sorted(Draft202012Validator(schema).iter_errors(jsonDocument), key=lambda error: list(error.path))
1041 if len(errors) > 0:
1042 ex = CodeCoverageError(f"Validation error for '{self._path}' using JSON Schema '{schemaFile}'.")
1043 for error in errors:
1044 ex.add_note(f"/{'/'.join(str(part) for part in error.path)}: {error.message}")
1045 raise ex
1047 self._jsonDocument = jsonDocument
1049 self._analysisDuration = sw.Duration
1051 def Convert(self) -> None:
1052 """
1053 Convert the parsed and validated JSON document to the format's model.
1055 :raises CodeCoverageError: If the JSON file was not analyzed before. |br|
1056 Call 'Document.Analyze()' or create the document using
1057 'Document(path, analyzeAndConvert=True)'.
1058 :raises CodeCoverageError: If the coverage.py version isn't a semantic version.
1059 :raises CodeCoverageError: If the timestamp isn't in ISO 8601 format.
1060 :raises CodeCoverageError: If the report names a file's path twice.
1061 """
1062 if self._jsonDocument is None:
1063 ex = CodeCoverageError(f"coverage.py report file '{self._path}' needs to be read and analyzed by a JSON parser.")
1064 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
1065 raise ex
1067 with Stopwatch() as sw:
1068 meta = self._jsonDocument["meta"]
1069 try:
1070 version = SemanticVersion.Parse(meta["version"])
1071 except ValueError as cause:
1072 ex = CodeCoverageError(f"coverage.py report file '{self._path}' states a malformed coverage.py version.")
1073 ex.add_note(f"Got value '{meta['version']}' at '/meta/version'.")
1074 raise ex from cause
1076 try:
1077 timestamp = datetime.fromisoformat(meta["timestamp"])
1078 except ValueError as cause:
1079 ex = CodeCoverageError(f"coverage.py report file '{self._path}' states a malformed timestamp.")
1080 ex.add_note(f"Got value '{meta['timestamp']}' at '/meta/timestamp'.")
1081 raise ex from cause
1083 self._format = FormatVersion(meta["format"])
1084 self._version = version
1085 self._timestamp = timestamp
1086 self._branchCoverage = meta["branch_coverage"]
1087 self._hasContexts = meta["show_contexts"]
1088 self._totals = Summary.Parse(self._jsonDocument["totals"])
1090 for name, record in self._jsonDocument["files"].items():
1091 File.Parse(name, record, parent=self)
1093 self._conversionDuration = sw.Duration
1095 def ToCoverageSummary(self) -> CoverageSummary:
1096 """
1097 Convert the format's model to the common model, and aggregate it.
1099 :returns: The report's root of the common model, named after the report file.
1100 :raises CodeCoverageError: If a file's path runs through another file.
1101 """
1102 summary = CoverageSummary(self._path.stem)
1104 for file in self._files.values():
1105 commonFile = summary.GetOrAddFile(file._path)
1106 self._ConvertLines(file, commonFile)
1107 self._ConvertUnits(file, commonFile, summary)
1109 summary.Aggregate()
1110 return summary
1112 @staticmethod
1113 def _ConvertLines(file: File, commonFile: cc_File) -> None:
1114 """
1115 Convert a file's lines and branches to lines of the common model.
1117 :param file: The file of the format's model.
1118 :param commonFile: The file of the common model.
1119 """
1120 missingSources = {source for source, _ in file._missingBranches}
1122 statuses: dict[int, LineCoverageStatus] = {}
1123 for number in file._executedLines:
1124 partial = number in missingSources
1125 statuses[number] = LineCoverageStatus.PartiallyCovered if partial else LineCoverageStatus.Covered
1127 for number in file._missingLines:
1128 statuses[number] = LineCoverageStatus.Uncovered
1130 for number in file._excludedLines:
1131 statuses[number] = LineCoverageStatus.Excluded
1133 for number in sorted(statuses):
1134 cc_Line(number, statuses[number], parent=commonFile)
1136 lines = commonFile._lines
1137 lastLineNumber = commonFile._lastLineNumber
1138 for status, arcs in (
1139 (LineCoverageStatus.Covered, file._executedBranches),
1140 (LineCoverageStatus.Uncovered, file._missingBranches)
1141 ):
1142 for source, target in arcs:
1143 cc_Branch(status, target=lines[target] if 0 < target <= lastLineNumber else None, parent=lines[source])
1145 @staticmethod
1146 def _ConvertUnits(file: File, commonFile: cc_File, summary: CoverageSummary) -> None:
1147 """
1148 Convert a file to units: its directories to packages, the file to a module, its classes and functions to classes,
1149 methods and functions.
1151 :param file: The file of the format's model.
1152 :param commonFile: The file of the common model.
1153 :param summary: The report's root of the common model.
1154 """
1155 path = file._path
1156 parent: cc_Unit | CoverageSummary = summary
1157 for part in path.parent.parts:
1158 parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent)
1160 lines = commonFile._lines
1161 lastLineNumber = commonFile._lastLineNumber
1162 module = cc_Module(
1163 path.stem,
1164 file=commonFile,
1165 startLine=next(commonFile.IterateLines(), None),
1166 endLine=lines[lastLineNumber] if lastLineNumber > 0 else None,
1167 parent=parent
1168 )
1170 for kind, regions in ((RegionKind.Class, file._classes), (RegionKind.Function, file._functions)):
1171 for name in sorted(regions):
1172 region = regions[name]
1173 *outerNames, ownName = name.split(".")
1175 # the enclosing class or function, found by the outer names; a region outside of a known one is left out
1176 container: Nullable[cc_Unit] = module
1177 for outerName in outerNames:
1178 if (container := container._units.get(outerName)) is None: 1178 ↛ 1179line 1178 didn't jump to line 1179 because the condition on line 1178 was never true
1179 break
1181 if container is None or ownName in container._units: 1181 ↛ 1182line 1181 didn't jump to line 1182 because the condition on line 1181 was never true
1182 continue
1184 if kind is RegionKind.Class:
1185 unitClass: type[cc_Unit] = cc_Class
1186 elif isinstance(container, cc_Class):
1187 unitClass = cc_Method
1188 else:
1189 unitClass = cc_Function
1191 numbers = [
1192 number for number in (region._startLine, *region.AllLines)
1193 if number <= lastLineNumber and lines[number] is not None
1194 ]
1196 unitClass(
1197 ownName,
1198 file=commonFile,
1199 startLine=lines[min(numbers)] if len(numbers) > 0 else None,
1200 endLine=lines[max(numbers)] if len(numbers) > 0 else None,
1201 parent=container
1202 )