Coverage for pyEDAA/Reports/CodeCoverage/GHDL.py: 99%
301 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 2026-2026 Electronic Design Automation Abstraction (EDA²) #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32GHDL's JSON code coverage format: a model of the format, read from coverage files, merged, and converted to the common
33model.
35GHDL writes the format when simulating with ``ghdl -r --coverage``, by default to :file:`coverage-<timestamp>.json`. A
36coverage file is validated against the JSON Schema of the format version it states, listed in :data:`SCHEMAS` - e.g.
37:file:`GHDL-Coverage-1.0.0.schema.json`, reverse-engineered from GHDL. The format's model keeps what the file states: a
38:class:`Document` holds :class:`File` records, each with its checksum and, per line with a coverage point, whether the
39line ran.
41:class:`MergedReport` merges the coverage files of several simulation runs, as ``ghdl coverage`` reads several files.
42:meth:`Document.ToCoverageSummary` and :meth:`MergedReport.ToCoverageSummary` convert to the common model of
43:mod:`pyEDAA.Reports.CodeCoverage`:
45* A file's path is the name it was analyzed by - relative to the directory GHDL ran in -, or, if analyzed in another
46 directory, prefixed by that directory.
47* A line that ran is covered, a line that didn't uncovered. The format states statement coverage as a flag per line:
48 it has no counts, no branches and no excluded lines.
49* The format names no units: no design units, processes or subprograms.
51.. rubric:: Example
53.. code-block:: Python
55 from pathlib import Path
56 from pyEDAA.Reports.CodeCoverage.GHDL import Document, MergedReport
58 documents = []
59 for path in sorted(Path(".").glob("coverage-*.json")):
60 documents.append(Document(path, analyzeAndConvert=True))
62 summary = MergedReport("Counter", documents).ToCoverageSummary()
63 print(f"{summary.FileCount} files: {summary.LineCoverage:.1%}")
64"""
65from __future__ import annotations
67from datetime import datetime, timezone
68from json import JSONDecodeError, loads
69from pathlib import Path
70from re import fullmatch
71from typing import Any, Generic, Iterable, Optional as Nullable, TypeVar
73from pyTooling.Common import getFullyQualifiedName, readResourceFile, StringEnum
74from pyTooling.Decorators import export, readonly
75from pyTooling.Exceptions import ToolingException
76from pyTooling.MetaClasses import ExtendedType
77from pyTooling.Stopwatch import Stopwatch
79from pyEDAA.Reports import Resources
80from pyEDAA.Reports.CodeCoverage import CodeCoverageError, CoverageSummary, Document as cc_Document, Line as cc_Line, \
81 LineCoverageStatus
84__all__ = ["SCHEMAS", "ParentType"]
86# A class with a property named like a class - ``Path`` - can't name that class in the annotation of a field: the class
87# body's namespace, where annotations are evaluated, binds the name to the property.
88_Path = Path
90ParentType = TypeVar("ParentType", bound="Report | MergedReport") #: The report a :class:`File` belongs to.
93@export
94class FormatVersion(StringEnum):
95 """
96 Version of GHDL's coverage format, as a coverage file's ``version`` states it.
98 GHDL's writer :file:`src/ghdldrv/ghdlcovout.adb` writes only version ``1.0.0``.
99 """
101 Version1_0_0 = "1.0.0" #: Format version 1.0.0.
104SCHEMAS: dict[FormatVersion, str] = {
105 FormatVersion.Version1_0_0: "GHDL-Coverage-1.0.0.schema.json",
106} #: The JSON Schema a coverage file is validated against, per format version.
109@export
110class CoverageMode(StringEnum):
111 """
112 Kind of coverage of a source file, as stated by an entry's ``mode``.
114 GHDL writes only ``stmt``: its writer :file:`src/ghdldrv/ghdlcovout.adb` knows no other kind of coverage, and its
115 reader :file:`src/ghdldrv/ghdlcov.adb` skips the field.
116 """
118 Statement = "stmt" #: Statement coverage: per line with a statement, whether a statement of the line ran.
121@export
122class File(Generic[ParentType], metaclass=ExtendedType, slots=True):
123 """
124 An entry of ``outputs``: a source file, where it was analyzed, its checksum, and its lines with coverage points.
125 """
127 _parent: Nullable[ParentType] #: The report the file belongs to.
128 _name: _Path #: The file's name, as given to the analysis.
129 _directory: _Path #: The directory the file was analyzed in; ``.`` if GHDL ran there.
130 _sha1: str #: SHA-1 checksum of the file's content.
131 _mode: CoverageMode #: Kind of coverage, e.g. statement coverage.
132 _lastLine: int #: The last line with a coverage point.
133 _result: dict[int, bool] #: Per line with a coverage point, whether the line ran.
135 def __init__(
136 self,
137 name: Path,
138 directory: Path,
139 sha1: str,
140 mode: CoverageMode,
141 lastLine: int,
142 result: dict[int, bool],
143 *,
144 parent: Nullable[ParentType] = None
145 ) -> None:
146 """
147 Initialize the file from the fields of its JSON object, and add it to the files of its report.
149 :param name: The file's name, as given to the analysis.
150 :param directory: The directory the file was analyzed in; ``.`` for the directory GHDL ran in.
151 :param sha1: SHA-1 checksum of the file's content.
152 :param mode: Kind of coverage, e.g. statement coverage.
153 :param lastLine: The last line with a coverage point.
154 :param result: Per line with a coverage point, whether the line ran.
155 :param parent: Optional, the report the file belongs to; the file is added to its files by :attr:`Path`.
156 Default: ``None``.
157 :raises ValueError: If parameter ``name`` is ``None``.
158 :raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
159 :raises ValueError: If parameter ``directory`` is ``None``.
160 :raises TypeError: If parameter ``directory`` isn't of type :class:`~pathlib.Path`.
161 :raises ValueError: If parameter ``sha1`` is ``None``.
162 :raises TypeError: If parameter ``sha1`` isn't of type :class:`str`.
163 :raises ValueError: If parameter ``sha1`` isn't 40 lowercase hexadecimal digits.
164 :raises ValueError: If parameter ``mode`` is ``None``.
165 :raises TypeError: If parameter ``mode`` isn't of type :class:`CoverageMode`.
166 :raises ValueError: If parameter ``lastLine`` is ``None``.
167 :raises TypeError: If parameter ``lastLine`` isn't of type :class:`int`.
168 :raises ValueError: If parameter ``lastLine`` is less than 1.
169 :raises ValueError: If parameter ``result`` is ``None``.
170 :raises TypeError: If parameter ``result`` isn't of type :class:`dict`.
171 :raises TypeError: If parameter ``result`` contains a line number not of type :class:`int`.
172 :raises ValueError: If parameter ``result`` contains a line number less than 1.
173 :raises ValueError: If parameter ``result`` contains a line number greater than ``lastLine``.
174 :raises TypeError: If parameter ``result`` contains a flag not of type :class:`bool`.
175 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report` or :class:`MergedReport`.
176 :raises ValueError: If parameter ``parent`` contains a file of the same path already.
177 """
178 if name is None:
179 raise ValueError("Parameter 'name' is None.")
180 elif not isinstance(name, Path):
181 ex = TypeError("Parameter 'name' is not of type 'Path'.")
182 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
183 raise ex
185 if directory is None:
186 raise ValueError("Parameter 'directory' is None.")
187 elif not isinstance(directory, Path):
188 ex = TypeError("Parameter 'directory' is not of type 'Path'.")
189 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.")
190 raise ex
192 if sha1 is None:
193 raise ValueError("Parameter 'sha1' is None.")
194 elif not isinstance(sha1, str):
195 ex = TypeError("Parameter 'sha1' is not of type 'str'.")
196 ex.add_note(f"Got type '{getFullyQualifiedName(sha1)}'.")
197 raise ex
198 elif fullmatch(r"[0-9a-f]{40}", sha1) is None:
199 ex = ValueError("Parameter 'sha1' is not 40 lowercase hexadecimal digits.")
200 ex.add_note(f"Got value '{sha1}'.")
201 raise ex
203 if mode is None:
204 raise ValueError("Parameter 'mode' is None.")
205 elif not isinstance(mode, CoverageMode):
206 ex = TypeError("Parameter 'mode' is not of type 'CoverageMode'.")
207 ex.add_note(f"Got type '{getFullyQualifiedName(mode)}'.")
208 raise ex
210 if lastLine is None:
211 raise ValueError("Parameter 'lastLine' is None.")
212 elif not isinstance(lastLine, int):
213 ex = TypeError("Parameter 'lastLine' is not of type 'int'.")
214 ex.add_note(f"Got type '{getFullyQualifiedName(lastLine)}'.")
215 raise ex
216 elif lastLine < 1:
217 ex = ValueError("Parameter 'lastLine' is less than 1.")
218 ex.add_note(f"Got value '{lastLine}'.")
219 raise ex
221 if result is None:
222 raise ValueError("Parameter 'result' is None.")
223 elif not isinstance(result, dict):
224 ex = TypeError("Parameter 'result' is not of type 'dict'.")
225 ex.add_note(f"Got type '{getFullyQualifiedName(result)}'.")
226 raise ex
228 for number, ran in result.items():
229 if not isinstance(number, int):
230 ex = TypeError("Parameter 'result' contains a line number not of type 'int'.")
231 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
232 raise ex
233 elif number < 1:
234 ex = ValueError("Parameter 'result' contains a line number less than 1.")
235 ex.add_note(f"Got value '{number}'.")
236 raise ex
237 elif number > lastLine:
238 ex = ValueError("Parameter 'result' contains a line number greater than parameter 'lastLine'.")
239 ex.add_note(f"Got line {number} for 'lastLine' {lastLine}.")
240 raise ex
241 elif not isinstance(ran, bool):
242 ex = TypeError("Parameter 'result' contains a flag not of type 'bool'.")
243 ex.add_note(f"Got type '{getFullyQualifiedName(ran)}'.")
244 raise ex
246 if parent is not None:
247 if not isinstance(parent, (Report, MergedReport)):
248 ex = TypeError("Parameter 'parent' is not of type 'Report' or 'MergedReport'.")
249 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
250 raise ex
251 elif (path := directory / name) in parent._files:
252 raise ValueError(f"Parameter 'parent' contains file '{path.as_posix()}' already.")
254 self._parent = parent
255 self._name = name
256 self._directory = directory
257 self._sha1 = sha1
258 self._mode = mode
259 self._lastLine = lastLine
260 self._result = result
262 if parent is not None:
263 parent._files[self.Path] = self
265 @readonly
266 def Parent(self) -> Nullable[ParentType]:
267 """
268 Read-only property to access the report the file belongs to (:attr:`_parent`).
270 :returns: The :class:`Report` or :class:`MergedReport`; ``None`` if the file belongs to no report.
271 """
272 return self._parent
274 @readonly
275 def Name(self) -> Path:
276 """
277 Read-only property to access the file's name, as given to the analysis (:attr:`_name`).
279 A file analyzed as ``ghdl -a src/Counter.vhdl`` is named ``src/Counter.vhdl``.
281 :returns: The name.
282 """
283 return self._name
285 @readonly
286 def Directory(self) -> Path:
287 """
288 Read-only property to access the directory the file was analyzed in (:attr:`_directory`).
290 :returns: The directory; ``.`` for the directory GHDL ran in and for an absolute name.
291 """
292 return self._directory
294 @readonly
295 def Path(self) -> Path:
296 """
297 Read-only property to return the file's path: its :attr:`Name`, prefixed by its :attr:`Directory` unless ``.``.
299 :returns: The path, as ``ghdl coverage`` names the file.
300 """
301 return self._directory / self._name
303 @readonly
304 def SHA1(self) -> str:
305 """
306 Read-only property to access the SHA-1 checksum of the file's content (:attr:`_sha1`).
308 :returns: The checksum, 40 hexadecimal digits.
309 """
310 return self._sha1
312 @readonly
313 def Mode(self) -> CoverageMode:
314 """
315 Read-only property to access the kind of coverage (:attr:`_mode`).
317 GHDL writes only statement coverage (:attr:`CoverageMode.Statement`).
319 :returns: The kind of coverage.
320 """
321 return self._mode
323 @readonly
324 def LastLine(self) -> int:
325 """
326 Read-only property to access the last line with a coverage point (:attr:`_lastLine`).
328 :returns: The line number.
329 """
330 return self._lastLine
332 @readonly
333 def Result(self) -> dict[int, bool]:
334 """
335 Read-only property to access whether each line with a coverage point ran (:attr:`_result`).
337 :returns: Per line number, ``True`` if a statement of the line ran.
338 """
339 return self._result
342@export
343class Report(metaclass=ExtendedType, mixin=True):
344 """
345 The coverage file's root: the format's version, when it was written, and the source files.
346 """
348 _version: Nullable[FormatVersion] #: Version of the format.
349 _testcase: Nullable[str] #: Name of the testcase.
350 _timestamp: Nullable[datetime] #: Time the file was written, UTC.
351 _files: dict[Path, File[Report]] #: The source files, by path.
353 def __init__(self) -> None:
354 """
355 Initialize an empty report.
356 """
357 self._version = None
358 self._testcase = None
359 self._timestamp = None
360 self._files = {}
362 @readonly
363 def Version(self) -> Nullable[FormatVersion]:
364 """
365 Read-only property to access the version of the format (:attr:`_version`).
367 :returns: The version; ``None`` before the coverage file was converted.
368 """
369 return self._version
371 @readonly
372 def Testcase(self) -> Nullable[str]:
373 """
374 Read-only property to access the name of the testcase (:attr:`_testcase`). GHDL writes ``unknown``.
376 :returns: The name; ``None`` before the coverage file was converted.
377 """
378 return self._testcase
380 @readonly
381 def Timestamp(self) -> Nullable[datetime]:
382 """
383 Read-only property to access the time the coverage file was written (:attr:`_timestamp`).
385 GHDL states it in UTC, to the millisecond, as ``YYYYMMDDhhmmss.mmm``.
387 :returns: The time, with time zone UTC; ``None`` before the coverage file was converted.
388 """
389 return self._timestamp
391 @readonly
392 def Files(self) -> dict[Path, File[Report]]:
393 """
394 Read-only property to access the source files (:attr:`_files`).
396 :returns: The files, by :attr:`File.Path`.
397 """
398 return self._files
401@export
402class Document(cc_Document, Report):
403 """
404 A GHDL coverage file: read into the format's model, and converted to the common model.
405 """
407 _jsonDocument: Nullable[dict[str, Any]] #: The parsed and validated JSON document, after :meth:`Analyze`.
409 def __init__(self, jsonReportFile: Path, analyzeAndConvert: bool = False) -> None:
410 """
411 Initialize the report, and optionally read it.
413 :param jsonReportFile: Path to the JSON file.
414 :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
415 """
416 super().__init__(jsonReportFile)
417 Report.__init__(self)
419 self._jsonDocument = None
421 if analyzeAndConvert:
422 self.Analyze()
423 self.Convert()
425 def Analyze(self) -> None:
426 """
427 Parse the JSON file, read its format version and validate it against the version's JSON Schema in :data:`SCHEMAS`.
429 :raises CodeCoverageError: If the file doesn't exist.
430 :raises CodeCoverageError: If the file can't be read.
431 :raises CodeCoverageError: If the file isn't UTF-8 encoded.
432 :raises CodeCoverageError: If the file isn't valid JSON.
433 :raises CodeCoverageError: If the file states no format version. |br|
434 The note lists the supported format versions.
435 :raises CodeCoverageError: If the file states an unsupported format version. |br|
436 The note lists the supported format versions.
437 :raises CodeCoverageError: If the JSON Schema can't be read.
438 :raises CodeCoverageError: If the file isn't valid according to the JSON Schema.
439 """
440 if not self._path.exists():
441 raise CodeCoverageError(f"GHDL coverage file '{self._path}' does not exist.") \
442 from FileNotFoundError(f"File '{self._path}' not found.")
444 from jsonschema import Draft202012Validator
446 with Stopwatch() as sw:
447 try:
448 content = self._path.read_text(encoding="utf-8")
449 except OSError as ex:
450 raise CodeCoverageError(f"Couldn't read GHDL coverage file '{self._path}'.") from ex
451 except UnicodeDecodeError as ex:
452 raise CodeCoverageError(f"GHDL coverage file '{self._path}' is not UTF-8 encoded.") from ex
454 try:
455 jsonDocument = loads(content)
456 except JSONDecodeError as ex:
457 raise CodeCoverageError(f"JSON syntax error in GHDL coverage file '{self._path}'.") from ex
459 version = jsonDocument.get("version", None) if isinstance(jsonDocument, dict) else None
460 try:
461 formatVersion = FormatVersion.Parse(version)
462 except (TypeError, ValueError) as cause:
463 ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states unsupported format version '{version}'.")
464 ex.add_note(f"Supported format versions: {', '.join(FormatVersion)}.")
465 raise ex from cause
467 if formatVersion is None:
468 ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states no format version.")
469 ex.add_note(f"Supported format versions: {', '.join(FormatVersion)}.")
470 raise ex
472 schemaFile = SCHEMAS[formatVersion]
473 try:
474 schema = loads(readResourceFile(Resources, schemaFile))
475 except (ToolingException, JSONDecodeError) as ex:
476 raise CodeCoverageError(f"Couldn't read JSON Schema '{schemaFile}' from package resources.") from ex
478 errors = sorted(Draft202012Validator(schema).iter_errors(jsonDocument), key=lambda error: list(error.path))
479 if len(errors) > 0:
480 ex = CodeCoverageError(f"Validation error for '{self._path}' using JSON Schema '{schemaFile}'.")
481 for error in errors:
482 ex.add_note(f"/{'/'.join(str(part) for part in error.path)}: {error.message}")
483 raise ex
485 self._jsonDocument = jsonDocument
487 self._analysisDuration = sw.Duration
489 def Convert(self) -> None:
490 """
491 Convert the parsed and validated JSON document to the format's model.
493 :raises CodeCoverageError: If the JSON file was not analyzed before. |br|
494 Call 'Document.Analyze()' or create the document using
495 'Document(path, analyzeAndConvert=True)'.
496 :raises CodeCoverageError: If the timestamp isn't a valid date and time.
497 :raises CodeCoverageError: If the JSON file names a source file twice.
498 :raises CodeCoverageError: If a source file's result names a line beyond its ``max-line``.
499 """
500 if self._jsonDocument is None:
501 ex = CodeCoverageError(f"GHDL coverage file '{self._path}' needs to be read and analyzed by a JSON parser.")
502 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
503 raise ex
505 with Stopwatch() as sw:
506 try:
507 timestamp = datetime.strptime(self._jsonDocument["timestamp"], "%Y%m%d%H%M%S.%f")
508 except ValueError as cause:
509 ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states a malformed timestamp.")
510 ex.add_note(f"Got value '{self._jsonDocument['timestamp']}' at '/timestamp'.")
511 raise ex from cause
513 self._version = FormatVersion.Parse(self._jsonDocument["version"])
514 self._testcase = self._jsonDocument["testcase"]
515 self._timestamp = timestamp.replace(tzinfo=timezone.utc)
517 for output in self._jsonDocument["outputs"]:
518 name = Path(output["file"].replace("\\", "/"))
519 directory = Path(output["dir"].replace("\\", "/"))
520 lastLine = output["max-line"]
521 result = {int(number): ran == 1 for number, ran in output["result"].items()}
523 if (path := directory / name) in self._files:
524 raise CodeCoverageError(f"GHDL coverage file '{self._path}' names source file '{path.as_posix()}' twice.")
525 elif (line := max(result)) > lastLine:
526 ex = CodeCoverageError(
527 f"GHDL coverage file '{self._path}' names a line of '{path.as_posix()}' beyond 'max-line'."
528 )
529 ex.add_note(f"Got line {line} for 'max-line' {lastLine}.")
530 raise ex
532 File(name, directory, output["sha1"], CoverageMode.Parse(output["mode"]), lastLine, result, parent=self)
534 self._conversionDuration = sw.Duration
536 def ToCoverageSummary(self) -> CoverageSummary:
537 """
538 Convert the format's model to the common model, and aggregate it.
540 :returns: The report's root of the common model, named after the coverage file.
541 :raises CodeCoverageError: If a file's path runs through another file.
542 """
543 return MergedReport(self._path.stem, (self, )).ToCoverageSummary()
546@export
547class MergedReport(metaclass=ExtendedType, slots=True):
548 """
549 The coverage files of several simulation runs, merged: a line ran, if it ran in one of the runs.
551 ``ghdl coverage`` reads several coverage files too, but a line's result there is the result of the last file naming
552 the line.
553 """
555 _name: str #: Name of the merged report.
556 _reports: list[Report] #: The merged reports, in the order they were merged.
557 _files: dict[Path, File[MergedReport]] #: The merged source files, by path.
559 def __init__(self, name: str, reports: Nullable[Iterable[Report]] = None) -> None:
560 """
561 Initialize a merged report, and merge the given reports.
563 :param name: Name of the merged report, e.g. of the design simulated.
564 :param reports: Optional, the reports to merge. Default: ``None``.
565 :raises ValueError: If parameter ``name`` is ``None``.
566 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
567 :raises ValueError: If parameter ``name`` is empty.
568 :raises TypeError: If parameter ``reports`` isn't iterable.
569 :raises TypeError: If parameter ``reports`` contains an element not of type :class:`Report`.
570 :raises CodeCoverageError: If a source file's checksum differs between two reports.
571 """
572 if name is None:
573 raise ValueError("Parameter 'name' is None.")
574 elif not isinstance(name, str):
575 ex = TypeError("Parameter 'name' is not of type 'str'.")
576 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
577 raise ex
578 elif name == "":
579 raise ValueError("Parameter 'name' is empty.")
581 self._name = name
582 self._reports = []
583 self._files = {}
585 if reports is not None:
586 if not isinstance(reports, Iterable):
587 ex = TypeError("Parameter 'reports' is not iterable.")
588 ex.add_note(f"Got type '{getFullyQualifiedName(reports)}'.")
589 raise ex
591 for report in reports:
592 if not isinstance(report, Report):
593 ex = TypeError("Parameter 'reports' contains an element not of type 'Report'.")
594 ex.add_note(f"Got type '{getFullyQualifiedName(report)}'.")
595 raise ex
597 self.Merge(report)
599 def Merge(self, report: Report) -> None:
600 """
601 Merge a report: add its source files, or merge them into the files of the same path.
603 A merged line ran, if it ran in one of the reports; a merged file's :attr:`File.LastLine` is the largest.
605 :param report: The report to merge.
606 :raises ValueError: If parameter ``report`` is ``None``.
607 :raises TypeError: If parameter ``report`` isn't of type :class:`Report`.
608 :raises CodeCoverageError: If a source file's checksum differs from the checksum in a report merged before.
609 """
610 if report is None:
611 raise ValueError("Parameter 'report' is None.")
612 elif not isinstance(report, Report):
613 ex = TypeError("Parameter 'report' is not of type 'Report'.")
614 ex.add_note(f"Got type '{getFullyQualifiedName(report)}'.")
615 raise ex
617 for path, file in report._files.items():
618 if (merged := self._files.get(path)) is None:
619 File(
620 file._name, file._directory, file._sha1, file._mode, file._lastLine, dict(file._result), parent=self
621 )
622 elif merged._sha1 != file._sha1:
623 ex = CodeCoverageError(f"Content of source file '{path.as_posix()}' differs from the reports merged before.")
624 ex.add_note(f"Got SHA-1 checksum '{file._sha1}' instead of '{merged._sha1}'.")
625 raise ex
626 else:
627 merged._lastLine = max(merged._lastLine, file._lastLine)
628 result = merged._result
629 for number, ran in file._result.items():
630 result[number] = ran or result.get(number, False)
632 self._reports.append(report)
634 @readonly
635 def Name(self) -> str:
636 """
637 Read-only property to access the name of the merged report (:attr:`_name`).
639 :returns: The name.
640 """
641 return self._name
643 @readonly
644 def Reports(self) -> list[Report]:
645 """
646 Read-only property to access the merged reports (:attr:`_reports`).
648 :returns: The reports, in the order they were merged.
649 """
650 return self._reports
652 @readonly
653 def Files(self) -> dict[Path, File[MergedReport]]:
654 """
655 Read-only property to access the merged source files (:attr:`_files`).
657 :returns: The files, by :attr:`File.Path`.
658 """
659 return self._files
661 def ToCoverageSummary(self) -> CoverageSummary:
662 """
663 Convert the merged source files to the common model, and aggregate it.
665 :returns: The report's root of the common model, named after the merged report.
666 :raises CodeCoverageError: If a file's path runs through another file.
667 """
668 summary = CoverageSummary(self._name)
670 for path, file in self._files.items():
671 commonFile = summary.GetOrAddFile(path)
672 result = file._result
673 for number in sorted(result):
674 cc_Line(
675 number,
676 LineCoverageStatus.Covered if result[number] else LineCoverageStatus.Uncovered,
677 parent=commonFile
678 )
680 summary.Aggregate()
681 return summary