Coverage for pyEDAA/Reports/CodeCoverage/GHDL.py: 99%
302 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 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 jsonschema import Draft202012Validator
74from pyTooling.Common import getFullyQualifiedName, readResourceFile, StringEnum
75from pyTooling.Decorators import export, readonly
76from pyTooling.Exceptions import ToolingException
77from pyTooling.MetaClasses import ExtendedType
78from pyTooling.Stopwatch import Stopwatch
80from pyEDAA.Reports import Resources
81from pyEDAA.Reports.CodeCoverage import CodeCoverageError, CoverageSummary, Document as cc_Document, Line as cc_Line
82from pyEDAA.Reports.CodeCoverage import LineCoverageStatus
85__all__ = ["SCHEMAS", "ParentType"]
87# A class with a property named like a class - ``Path`` - can't name that class in the annotation of a field: the class
88# body's namespace, where annotations are evaluated, binds the name to the property.
89_Path = Path
91ParentType = TypeVar("ParentType", bound="Report | MergedReport") #: The report a :class:`File` belongs to.
94@export
95class FormatVersion(StringEnum):
96 """
97 Version of GHDL's coverage format, as a coverage file's ``version`` states it.
99 GHDL's writer :file:`src/ghdldrv/ghdlcovout.adb` writes only version ``1.0.0``.
100 """
102 Version1_0_0 = "1.0.0" #: Format version 1.0.0.
105SCHEMAS: dict[FormatVersion, str] = {
106 FormatVersion.Version1_0_0: "GHDL-Coverage-1.0.0.schema.json",
107} #: The JSON Schema a coverage file is validated against, per format version.
110@export
111class CoverageMode(StringEnum):
112 """
113 Kind of coverage of a source file, as stated by an entry's ``mode``.
115 GHDL writes only ``stmt``: its writer :file:`src/ghdldrv/ghdlcovout.adb` knows no other kind of coverage, and its
116 reader :file:`src/ghdldrv/ghdlcov.adb` skips the field.
117 """
119 Statement = "stmt" #: Statement coverage: per line with a statement, whether a statement of the line ran.
122@export
123class File(Generic[ParentType], metaclass=ExtendedType, slots=True):
124 """
125 An entry of ``outputs``: a source file, where it was analyzed, its checksum, and its lines with coverage points.
126 """
128 _parent: Nullable[ParentType] #: The report the file belongs to.
129 _name: _Path #: The file's name, as given to the analysis.
130 _directory: _Path #: The directory the file was analyzed in; ``.`` if GHDL ran there.
131 _sha1: str #: SHA-1 checksum of the file's content.
132 _mode: CoverageMode #: Kind of coverage, e.g. statement coverage.
133 _lastLine: int #: The last line with a coverage point.
134 _result: dict[int, bool] #: Per line with a coverage point, whether the line ran.
136 def __init__(
137 self,
138 name: Path,
139 directory: Path,
140 sha1: str,
141 mode: CoverageMode,
142 lastLine: int,
143 result: dict[int, bool],
144 *,
145 parent: Nullable[ParentType] = None
146 ) -> None:
147 """
148 Initialize the file from the fields of its JSON object, and add it to the files of its report.
150 :param name: The file's name, as given to the analysis.
151 :param directory: The directory the file was analyzed in; ``.`` for the directory GHDL ran in.
152 :param sha1: SHA-1 checksum of the file's content.
153 :param mode: Kind of coverage, e.g. statement coverage.
154 :param lastLine: The last line with a coverage point.
155 :param result: Per line with a coverage point, whether the line ran.
156 :param parent: Optional, the report the file belongs to; the file is added to its files by :attr:`Path`.
157 Default: ``None``.
158 :raises ValueError: If parameter ``name`` is ``None``.
159 :raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
160 :raises ValueError: If parameter ``directory`` is ``None``.
161 :raises TypeError: If parameter ``directory`` isn't of type :class:`~pathlib.Path`.
162 :raises ValueError: If parameter ``sha1`` is ``None``.
163 :raises TypeError: If parameter ``sha1`` isn't of type :class:`str`.
164 :raises ValueError: If parameter ``sha1`` isn't 40 lowercase hexadecimal digits.
165 :raises ValueError: If parameter ``mode`` is ``None``.
166 :raises TypeError: If parameter ``mode`` isn't of type :class:`CoverageMode`.
167 :raises ValueError: If parameter ``lastLine`` is ``None``.
168 :raises TypeError: If parameter ``lastLine`` isn't of type :class:`int`.
169 :raises ValueError: If parameter ``lastLine`` is less than 1.
170 :raises ValueError: If parameter ``result`` is ``None``.
171 :raises TypeError: If parameter ``result`` isn't of type :class:`dict`.
172 :raises TypeError: If parameter ``result`` contains a line number not of type :class:`int`.
173 :raises ValueError: If parameter ``result`` contains a line number less than 1.
174 :raises ValueError: If parameter ``result`` contains a line number greater than ``lastLine``.
175 :raises TypeError: If parameter ``result`` contains a flag not of type :class:`bool`.
176 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report` or :class:`MergedReport`.
177 :raises ValueError: If parameter ``parent`` contains a file of the same path already.
178 """
179 if name is None:
180 raise ValueError("Parameter 'name' is None.")
181 elif not isinstance(name, Path):
182 ex = TypeError("Parameter 'name' is not of type 'Path'.")
183 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
184 raise ex
186 if directory is None:
187 raise ValueError("Parameter 'directory' is None.")
188 elif not isinstance(directory, Path):
189 ex = TypeError("Parameter 'directory' is not of type 'Path'.")
190 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.")
191 raise ex
193 if sha1 is None:
194 raise ValueError("Parameter 'sha1' is None.")
195 elif not isinstance(sha1, str):
196 ex = TypeError("Parameter 'sha1' is not of type 'str'.")
197 ex.add_note(f"Got type '{getFullyQualifiedName(sha1)}'.")
198 raise ex
199 elif fullmatch(r"[0-9a-f]{40}", sha1) is None:
200 ex = ValueError("Parameter 'sha1' is not 40 lowercase hexadecimal digits.")
201 ex.add_note(f"Got value '{sha1}'.")
202 raise ex
204 if mode is None:
205 raise ValueError("Parameter 'mode' is None.")
206 elif not isinstance(mode, CoverageMode):
207 ex = TypeError("Parameter 'mode' is not of type 'CoverageMode'.")
208 ex.add_note(f"Got type '{getFullyQualifiedName(mode)}'.")
209 raise ex
211 if lastLine is None:
212 raise ValueError("Parameter 'lastLine' is None.")
213 elif not isinstance(lastLine, int):
214 ex = TypeError("Parameter 'lastLine' is not of type 'int'.")
215 ex.add_note(f"Got type '{getFullyQualifiedName(lastLine)}'.")
216 raise ex
217 elif lastLine < 1:
218 ex = ValueError("Parameter 'lastLine' is less than 1.")
219 ex.add_note(f"Got value '{lastLine}'.")
220 raise ex
222 if result is None:
223 raise ValueError("Parameter 'result' is None.")
224 elif not isinstance(result, dict):
225 ex = TypeError("Parameter 'result' is not of type 'dict'.")
226 ex.add_note(f"Got type '{getFullyQualifiedName(result)}'.")
227 raise ex
229 for number, ran in result.items():
230 if not isinstance(number, int):
231 ex = TypeError("Parameter 'result' contains a line number not of type 'int'.")
232 ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
233 raise ex
234 elif number < 1:
235 ex = ValueError("Parameter 'result' contains a line number less than 1.")
236 ex.add_note(f"Got value '{number}'.")
237 raise ex
238 elif number > lastLine:
239 ex = ValueError("Parameter 'result' contains a line number greater than parameter 'lastLine'.")
240 ex.add_note(f"Got line {number} for 'lastLine' {lastLine}.")
241 raise ex
242 elif not isinstance(ran, bool):
243 ex = TypeError("Parameter 'result' contains a flag not of type 'bool'.")
244 ex.add_note(f"Got type '{getFullyQualifiedName(ran)}'.")
245 raise ex
247 if parent is not None:
248 if not isinstance(parent, (Report, MergedReport)):
249 ex = TypeError("Parameter 'parent' is not of type 'Report' or 'MergedReport'.")
250 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
251 raise ex
252 elif (path := directory / name) in parent._files:
253 raise ValueError(f"Parameter 'parent' contains file '{path.as_posix()}' already.")
255 self._parent = parent
256 self._name = name
257 self._directory = directory
258 self._sha1 = sha1
259 self._mode = mode
260 self._lastLine = lastLine
261 self._result = result
263 if parent is not None:
264 parent._files[self.Path] = self
266 @readonly
267 def Parent(self) -> Nullable[ParentType]:
268 """
269 Read-only property to access the report the file belongs to (:attr:`_parent`).
271 :returns: The :class:`Report` or :class:`MergedReport`; ``None`` if the file belongs to no report.
272 """
273 return self._parent
275 @readonly
276 def Name(self) -> Path:
277 """
278 Read-only property to access the file's name, as given to the analysis (:attr:`_name`).
280 A file analyzed as ``ghdl -a src/Counter.vhdl`` is named ``src/Counter.vhdl``.
282 :returns: The name.
283 """
284 return self._name
286 @readonly
287 def Directory(self) -> Path:
288 """
289 Read-only property to access the directory the file was analyzed in (:attr:`_directory`).
291 :returns: The directory; ``.`` for the directory GHDL ran in and for an absolute name.
292 """
293 return self._directory
295 @readonly
296 def Path(self) -> Path:
297 """
298 Read-only property to return the file's path: its :attr:`Name`, prefixed by its :attr:`Directory` unless ``.``.
300 :returns: The path, as ``ghdl coverage`` names the file.
301 """
302 return self._directory / self._name
304 @readonly
305 def SHA1(self) -> str:
306 """
307 Read-only property to access the SHA-1 checksum of the file's content (:attr:`_sha1`).
309 :returns: The checksum, 40 hexadecimal digits.
310 """
311 return self._sha1
313 @readonly
314 def Mode(self) -> CoverageMode:
315 """
316 Read-only property to access the kind of coverage (:attr:`_mode`).
318 GHDL writes only statement coverage (:attr:`CoverageMode.Statement`).
320 :returns: The kind of coverage.
321 """
322 return self._mode
324 @readonly
325 def LastLine(self) -> int:
326 """
327 Read-only property to access the last line with a coverage point (:attr:`_lastLine`).
329 :returns: The line number.
330 """
331 return self._lastLine
333 @readonly
334 def Result(self) -> dict[int, bool]:
335 """
336 Read-only property to access whether each line with a coverage point ran (:attr:`_result`).
338 :returns: Per line number, ``True`` if a statement of the line ran.
339 """
340 return self._result
343@export
344class Report(metaclass=ExtendedType, mixin=True):
345 """
346 The coverage file's root: the format's version, when it was written, and the source files.
347 """
349 _version: Nullable[FormatVersion] #: Version of the format.
350 _testcase: Nullable[str] #: Name of the testcase.
351 _timestamp: Nullable[datetime] #: Time the file was written, UTC.
352 _files: dict[Path, File[Report]] #: The source files, by path.
354 def __init__(self) -> None:
355 """
356 Initialize an empty report.
357 """
358 self._version = None
359 self._testcase = None
360 self._timestamp = None
361 self._files = {}
363 @readonly
364 def Version(self) -> Nullable[FormatVersion]:
365 """
366 Read-only property to access the version of the format (:attr:`_version`).
368 :returns: The version; ``None`` before the coverage file was converted.
369 """
370 return self._version
372 @readonly
373 def Testcase(self) -> Nullable[str]:
374 """
375 Read-only property to access the name of the testcase (:attr:`_testcase`). GHDL writes ``unknown``.
377 :returns: The name; ``None`` before the coverage file was converted.
378 """
379 return self._testcase
381 @readonly
382 def Timestamp(self) -> Nullable[datetime]:
383 """
384 Read-only property to access the time the coverage file was written (:attr:`_timestamp`).
386 GHDL states it in UTC, to the millisecond, as ``YYYYMMDDhhmmss.mmm``.
388 :returns: The time, with time zone UTC; ``None`` before the coverage file was converted.
389 """
390 return self._timestamp
392 @readonly
393 def Files(self) -> dict[Path, File[Report]]:
394 """
395 Read-only property to access the source files (:attr:`_files`).
397 :returns: The files, by :attr:`File.Path`.
398 """
399 return self._files
402@export
403class Document(cc_Document, Report):
404 """
405 A GHDL coverage file: read into the format's model, and converted to the common model.
406 """
408 _jsonDocument: Nullable[dict[str, Any]] #: The parsed and validated JSON document, after :meth:`Analyze`.
410 def __init__(self, jsonReportFile: Path, analyzeAndConvert: bool = False) -> None:
411 """
412 Initialize the report, and optionally read it.
414 :param jsonReportFile: Path to the JSON file.
415 :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
416 """
417 super().__init__(jsonReportFile)
418 Report.__init__(self)
420 self._jsonDocument = None
422 if analyzeAndConvert:
423 self.Analyze()
424 self.Convert()
426 def Analyze(self) -> None:
427 """
428 Parse the JSON file, read its format version and validate it against the version's JSON Schema in :data:`SCHEMAS`.
430 :raises CodeCoverageError: If the file doesn't exist.
431 :raises CodeCoverageError: If the file can't be read.
432 :raises CodeCoverageError: If the file isn't UTF-8 encoded.
433 :raises CodeCoverageError: If the file isn't valid JSON.
434 :raises CodeCoverageError: If the file states no format version. |br|
435 The note lists the supported format versions.
436 :raises CodeCoverageError: If the file states an unsupported format version. |br|
437 The note lists the supported format versions.
438 :raises CodeCoverageError: If the JSON Schema can't be read.
439 :raises CodeCoverageError: If the file isn't valid according to the JSON Schema.
440 """
441 if not self._path.exists():
442 raise CodeCoverageError(f"GHDL coverage file '{self._path}' does not exist.") \
443 from FileNotFoundError(f"File '{self._path}' not found.")
445 with Stopwatch() as sw:
446 try:
447 content = self._path.read_text(encoding="utf-8")
448 except OSError as ex:
449 raise CodeCoverageError(f"Couldn't read GHDL coverage file '{self._path}'.") from ex
450 except UnicodeDecodeError as ex:
451 raise CodeCoverageError(f"GHDL coverage file '{self._path}' is not UTF-8 encoded.") from ex
453 try:
454 jsonDocument = loads(content)
455 except JSONDecodeError as ex:
456 raise CodeCoverageError(f"JSON syntax error in GHDL coverage file '{self._path}'.") from ex
458 version = jsonDocument.get("version", None) if isinstance(jsonDocument, dict) else None
459 try:
460 formatVersion = FormatVersion.Parse(version)
461 except (TypeError, ValueError) as cause:
462 ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states unsupported format version '{version}'.")
463 ex.add_note(f"Supported format versions: {', '.join(FormatVersion)}.")
464 raise ex from cause
466 if formatVersion is None:
467 ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states no format version.")
468 ex.add_note(f"Supported format versions: {', '.join(FormatVersion)}.")
469 raise ex
471 schemaFile = SCHEMAS[formatVersion]
472 try:
473 schema = loads(readResourceFile(Resources, schemaFile))
474 except (ToolingException, JSONDecodeError) as ex:
475 raise CodeCoverageError(f"Couldn't read JSON Schema '{schemaFile}' from package resources.") from ex
477 errors = sorted(Draft202012Validator(schema).iter_errors(jsonDocument), key=lambda error: list(error.path))
478 if len(errors) > 0:
479 ex = CodeCoverageError(f"Validation error for '{self._path}' using JSON Schema '{schemaFile}'.")
480 for error in errors:
481 ex.add_note(f"/{'/'.join(str(part) for part in error.path)}: {error.message}")
482 raise ex
484 self._jsonDocument = jsonDocument
486 self._analysisDuration = sw.Duration
488 def Convert(self) -> None:
489 """
490 Convert the parsed and validated JSON document to the format's model.
492 :raises CodeCoverageError: If the JSON file was not analyzed before. |br|
493 Call 'Document.Analyze()' or create the document using
494 'Document(path, analyzeAndConvert=True)'.
495 :raises CodeCoverageError: If the timestamp isn't a valid date and time.
496 :raises CodeCoverageError: If the JSON file names a source file twice.
497 :raises CodeCoverageError: If a source file's result names a line beyond its ``max-line``.
498 """
499 if self._jsonDocument is None:
500 ex = CodeCoverageError(f"GHDL coverage file '{self._path}' needs to be read and analyzed by a JSON parser.")
501 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
502 raise ex
504 with Stopwatch() as sw:
505 try:
506 timestamp = datetime.strptime(self._jsonDocument["timestamp"], "%Y%m%d%H%M%S.%f")
507 except ValueError as cause:
508 ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states a malformed timestamp.")
509 ex.add_note(f"Got value '{self._jsonDocument['timestamp']}' at '/timestamp'.")
510 raise ex from cause
512 self._version = FormatVersion.Parse(self._jsonDocument["version"])
513 self._testcase = self._jsonDocument["testcase"]
514 self._timestamp = timestamp.replace(tzinfo=timezone.utc)
516 for output in self._jsonDocument["outputs"]:
517 name = Path(output["file"].replace("\\", "/"))
518 directory = Path(output["dir"].replace("\\", "/"))
519 lastLine = output["max-line"]
520 result = {int(number): ran == 1 for number, ran in output["result"].items()}
522 if (path := directory / name) in self._files:
523 raise CodeCoverageError(f"GHDL coverage file '{self._path}' names source file '{path.as_posix()}' twice.")
524 elif (line := max(result)) > lastLine:
525 ex = CodeCoverageError(
526 f"GHDL coverage file '{self._path}' names a line of '{path.as_posix()}' beyond 'max-line'."
527 )
528 ex.add_note(f"Got line {line} for 'max-line' {lastLine}.")
529 raise ex
531 File(name, directory, output["sha1"], CoverageMode.Parse(output["mode"]), lastLine, result, parent=self)
533 self._conversionDuration = sw.Duration
535 def ToCoverageSummary(self) -> CoverageSummary:
536 """
537 Convert the format's model to the common model, and aggregate it.
539 :returns: The report's root of the common model, named after the coverage file.
540 :raises CodeCoverageError: If a file's path runs through another file.
541 """
542 return MergedReport(self._path.stem, (self, )).ToCoverageSummary()
545@export
546class MergedReport(metaclass=ExtendedType, slots=True):
547 """
548 The coverage files of several simulation runs, merged: a line ran, if it ran in one of the runs.
550 ``ghdl coverage`` reads several coverage files too, but a line's result there is the result of the last file naming
551 the line.
552 """
554 _name: str #: Name of the merged report.
555 _reports: list[Report] #: The merged reports, in the order they were merged.
556 _files: dict[Path, File[MergedReport]] #: The merged source files, by path.
558 def __init__(self, name: str, reports: Nullable[Iterable[Report]] = None) -> None:
559 """
560 Initialize a merged report, and merge the given reports.
562 :param name: Name of the merged report, e.g. of the design simulated.
563 :param reports: Optional, the reports to merge. Default: ``None``.
564 :raises ValueError: If parameter ``name`` is ``None``.
565 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
566 :raises ValueError: If parameter ``name`` is empty.
567 :raises TypeError: If parameter ``reports`` isn't iterable.
568 :raises TypeError: If parameter ``reports`` contains an element not of type :class:`Report`.
569 :raises CodeCoverageError: If a source file's checksum differs between two reports.
570 """
571 if name is None:
572 raise ValueError("Parameter 'name' is None.")
573 elif not isinstance(name, str):
574 ex = TypeError("Parameter 'name' is not of type 'str'.")
575 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
576 raise ex
577 elif name == "":
578 raise ValueError("Parameter 'name' is empty.")
580 self._name = name
581 self._reports = []
582 self._files = {}
584 if reports is not None:
585 if not isinstance(reports, Iterable):
586 ex = TypeError("Parameter 'reports' is not iterable.")
587 ex.add_note(f"Got type '{getFullyQualifiedName(reports)}'.")
588 raise ex
590 for report in reports:
591 if not isinstance(report, Report):
592 ex = TypeError("Parameter 'reports' contains an element not of type 'Report'.")
593 ex.add_note(f"Got type '{getFullyQualifiedName(report)}'.")
594 raise ex
596 self.Merge(report)
598 def Merge(self, report: Report) -> None:
599 """
600 Merge a report: add its source files, or merge them into the files of the same path.
602 A merged line ran, if it ran in one of the reports; a merged file's :attr:`File.LastLine` is the largest.
604 :param report: The report to merge.
605 :raises ValueError: If parameter ``report`` is ``None``.
606 :raises TypeError: If parameter ``report`` isn't of type :class:`Report`.
607 :raises CodeCoverageError: If a source file's checksum differs from the checksum in a report merged before.
608 """
609 if report is None:
610 raise ValueError("Parameter 'report' is None.")
611 elif not isinstance(report, Report):
612 ex = TypeError("Parameter 'report' is not of type 'Report'.")
613 ex.add_note(f"Got type '{getFullyQualifiedName(report)}'.")
614 raise ex
616 for path, file in report._files.items():
617 if (merged := self._files.get(path)) is None:
618 File(
619 file._name, file._directory, file._sha1, file._mode, file._lastLine, dict(file._result), parent=self
620 )
621 elif merged._sha1 != file._sha1:
622 ex = CodeCoverageError(f"Content of source file '{path.as_posix()}' differs from the reports merged before.")
623 ex.add_note(f"Got SHA-1 checksum '{file._sha1}' instead of '{merged._sha1}'.")
624 raise ex
625 else:
626 merged._lastLine = max(merged._lastLine, file._lastLine)
627 result = merged._result
628 for number, ran in file._result.items():
629 result[number] = ran or result.get(number, False)
631 self._reports.append(report)
633 @readonly
634 def Name(self) -> str:
635 """
636 Read-only property to access the name of the merged report (:attr:`_name`).
638 :returns: The name.
639 """
640 return self._name
642 @readonly
643 def Reports(self) -> list[Report]:
644 """
645 Read-only property to access the merged reports (:attr:`_reports`).
647 :returns: The reports, in the order they were merged.
648 """
649 return self._reports
651 @readonly
652 def Files(self) -> dict[Path, File[MergedReport]]:
653 """
654 Read-only property to access the merged source files (:attr:`_files`).
656 :returns: The files, by :attr:`File.Path`.
657 """
658 return self._files
660 def ToCoverageSummary(self) -> CoverageSummary:
661 """
662 Convert the merged source files to the common model, and aggregate it.
664 :returns: The report's root of the common model, named after the merged report.
665 :raises CodeCoverageError: If a file's path runs through another file.
666 """
667 summary = CoverageSummary(self._name)
669 for path, file in self._files.items():
670 commonFile = summary.GetOrAddFile(path)
671 result = file._result
672 for number in sorted(result):
673 cc_Line(
674 number,
675 LineCoverageStatus.Covered if result[number] else LineCoverageStatus.Uncovered,
676 parent=commonFile
677 )
679 summary.Aggregate()
680 return summary