# ==================================================================================================================== #
# _____ ____ _ _ ____ _ #
# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
# |_| |___/ |_| #
# ==================================================================================================================== #
# Authors: #
# Patrick Lehmann #
# #
# License: #
# ==================================================================================================================== #
# Copyright 2026-2026 Electronic Design Automation Abstraction (EDA²) #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); #
# you may not use this file except in compliance with the License. #
# You may obtain a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
# #
# SPDX-License-Identifier: Apache-2.0 #
# ==================================================================================================================== #
#
"""
GHDL's JSON code coverage format: a model of the format, read from coverage files, merged, and converted to the common
model.
GHDL writes the format when simulating with ``ghdl -r --coverage``, by default to :file:`coverage-<timestamp>.json`. A
coverage file is validated against the JSON Schema of the format version it states, listed in :data:`SCHEMAS` - e.g.
:file:`GHDL-Coverage-1.0.0.schema.json`, reverse-engineered from GHDL. The format's model keeps what the file states: a
:class:`Document` holds :class:`File` records, each with its checksum and, per line with a coverage point, whether the
line ran.
:class:`MergedReport` merges the coverage files of several simulation runs, as ``ghdl coverage`` reads several files.
:meth:`Document.ToCoverageSummary` and :meth:`MergedReport.ToCoverageSummary` convert to the common model of
:mod:`pyEDAA.Reports.CodeCoverage`:
* A file's path is the name it was analyzed by - relative to the directory GHDL ran in -, or, if analyzed in another
directory, prefixed by that directory.
* A line that ran is covered, a line that didn't uncovered. The format states statement coverage as a flag per line:
it has no counts, no branches and no excluded lines.
* The format names no units: no design units, processes or subprograms.
.. rubric:: Example
.. code-block:: Python
from pathlib import Path
from pyEDAA.Reports.CodeCoverage.GHDL import Document, MergedReport
documents = []
for path in sorted(Path(".").glob("coverage-*.json")):
documents.append(Document(path, analyzeAndConvert=True))
summary = MergedReport("Counter", documents).ToCoverageSummary()
print(f"{summary.FileCount} files: {summary.LineCoverage:.1%}")
"""
from __future__ import annotations
from datetime import datetime, timezone
from json import JSONDecodeError, loads
from pathlib import Path
from re import fullmatch
from typing import Any, Generic, Iterable, Optional as Nullable, TypeVar
from pyTooling.Common import getFullyQualifiedName, readResourceFile, StringEnum
from pyTooling.Decorators import export, readonly
from pyTooling.Exceptions import ToolingException
from pyTooling.MetaClasses import ExtendedType
from pyTooling.Stopwatch import Stopwatch
from pyEDAA.Reports import Resources
from pyEDAA.Reports.CodeCoverage import CodeCoverageError, CoverageSummary, Document as cc_Document, Line as cc_Line, \
LineCoverageStatus
__all__ = ["SCHEMAS", "ParentType"]
# A class with a property named like a class - ``Path`` - can't name that class in the annotation of a field: the class
# body's namespace, where annotations are evaluated, binds the name to the property.
_Path = Path
ParentType = TypeVar("ParentType", bound="Report | MergedReport") #: The report a :class:`File` belongs to.
SCHEMAS: dict[FormatVersion, str] = {
FormatVersion.Version1_0_0: "GHDL-Coverage-1.0.0.schema.json",
} #: The JSON Schema a coverage file is validated against, per format version.
[docs]
@export
class CoverageMode(StringEnum):
"""
Kind of coverage of a source file, as stated by an entry's ``mode``.
GHDL writes only ``stmt``: its writer :file:`src/ghdldrv/ghdlcovout.adb` knows no other kind of coverage, and its
reader :file:`src/ghdldrv/ghdlcov.adb` skips the field.
"""
Statement = "stmt" #: Statement coverage: per line with a statement, whether a statement of the line ran.
[docs]
@export
class File(Generic[ParentType], metaclass=ExtendedType, slots=True):
"""
An entry of ``outputs``: a source file, where it was analyzed, its checksum, and its lines with coverage points.
"""
_parent: Nullable[ParentType] #: The report the file belongs to.
_name: _Path #: The file's name, as given to the analysis.
_directory: _Path #: The directory the file was analyzed in; ``.`` if GHDL ran there.
_sha1: str #: SHA-1 checksum of the file's content.
_mode: CoverageMode #: Kind of coverage, e.g. statement coverage.
_lastLine: int #: The last line with a coverage point.
_result: dict[int, bool] #: Per line with a coverage point, whether the line ran.
[docs]
def __init__(
self,
name: Path,
directory: Path,
sha1: str,
mode: CoverageMode,
lastLine: int,
result: dict[int, bool],
*,
parent: Nullable[ParentType] = None
) -> None:
"""
Initialize the file from the fields of its JSON object, and add it to the files of its report.
:param name: The file's name, as given to the analysis.
:param directory: The directory the file was analyzed in; ``.`` for the directory GHDL ran in.
:param sha1: SHA-1 checksum of the file's content.
:param mode: Kind of coverage, e.g. statement coverage.
:param lastLine: The last line with a coverage point.
:param result: Per line with a coverage point, whether the line ran.
:param parent: Optional, the report the file belongs to; the file is added to its files by :attr:`Path`.
Default: ``None``.
:raises ValueError: If parameter ``name`` is ``None``.
:raises TypeError: If parameter ``name`` isn't of type :class:`~pathlib.Path`.
:raises ValueError: If parameter ``directory`` is ``None``.
:raises TypeError: If parameter ``directory`` isn't of type :class:`~pathlib.Path`.
:raises ValueError: If parameter ``sha1`` is ``None``.
:raises TypeError: If parameter ``sha1`` isn't of type :class:`str`.
:raises ValueError: If parameter ``sha1`` isn't 40 lowercase hexadecimal digits.
:raises ValueError: If parameter ``mode`` is ``None``.
:raises TypeError: If parameter ``mode`` isn't of type :class:`CoverageMode`.
:raises ValueError: If parameter ``lastLine`` is ``None``.
:raises TypeError: If parameter ``lastLine`` isn't of type :class:`int`.
:raises ValueError: If parameter ``lastLine`` is less than 1.
:raises ValueError: If parameter ``result`` is ``None``.
:raises TypeError: If parameter ``result`` isn't of type :class:`dict`.
:raises TypeError: If parameter ``result`` contains a line number not of type :class:`int`.
:raises ValueError: If parameter ``result`` contains a line number less than 1.
:raises ValueError: If parameter ``result`` contains a line number greater than ``lastLine``.
:raises TypeError: If parameter ``result`` contains a flag not of type :class:`bool`.
:raises TypeError: If parameter ``parent`` isn't of type :class:`Report` or :class:`MergedReport`.
:raises ValueError: If parameter ``parent`` contains a file of the same path already.
"""
if name is None:
raise ValueError("Parameter 'name' is None.")
elif not isinstance(name, Path):
ex = TypeError("Parameter 'name' is not of type 'Path'.")
ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
raise ex
if directory is None:
raise ValueError("Parameter 'directory' is None.")
elif not isinstance(directory, Path):
ex = TypeError("Parameter 'directory' is not of type 'Path'.")
ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.")
raise ex
if sha1 is None:
raise ValueError("Parameter 'sha1' is None.")
elif not isinstance(sha1, str):
ex = TypeError("Parameter 'sha1' is not of type 'str'.")
ex.add_note(f"Got type '{getFullyQualifiedName(sha1)}'.")
raise ex
elif fullmatch(r"[0-9a-f]{40}", sha1) is None:
ex = ValueError("Parameter 'sha1' is not 40 lowercase hexadecimal digits.")
ex.add_note(f"Got value '{sha1}'.")
raise ex
if mode is None:
raise ValueError("Parameter 'mode' is None.")
elif not isinstance(mode, CoverageMode):
ex = TypeError("Parameter 'mode' is not of type 'CoverageMode'.")
ex.add_note(f"Got type '{getFullyQualifiedName(mode)}'.")
raise ex
if lastLine is None:
raise ValueError("Parameter 'lastLine' is None.")
elif not isinstance(lastLine, int):
ex = TypeError("Parameter 'lastLine' is not of type 'int'.")
ex.add_note(f"Got type '{getFullyQualifiedName(lastLine)}'.")
raise ex
elif lastLine < 1:
ex = ValueError("Parameter 'lastLine' is less than 1.")
ex.add_note(f"Got value '{lastLine}'.")
raise ex
if result is None:
raise ValueError("Parameter 'result' is None.")
elif not isinstance(result, dict):
ex = TypeError("Parameter 'result' is not of type 'dict'.")
ex.add_note(f"Got type '{getFullyQualifiedName(result)}'.")
raise ex
for number, ran in result.items():
if not isinstance(number, int):
ex = TypeError("Parameter 'result' contains a line number not of type 'int'.")
ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
raise ex
elif number < 1:
ex = ValueError("Parameter 'result' contains a line number less than 1.")
ex.add_note(f"Got value '{number}'.")
raise ex
elif number > lastLine:
ex = ValueError("Parameter 'result' contains a line number greater than parameter 'lastLine'.")
ex.add_note(f"Got line {number} for 'lastLine' {lastLine}.")
raise ex
elif not isinstance(ran, bool):
ex = TypeError("Parameter 'result' contains a flag not of type 'bool'.")
ex.add_note(f"Got type '{getFullyQualifiedName(ran)}'.")
raise ex
if parent is not None:
if not isinstance(parent, (Report, MergedReport)):
ex = TypeError("Parameter 'parent' is not of type 'Report' or 'MergedReport'.")
ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
raise ex
elif (path := directory / name) in parent._files:
raise ValueError(f"Parameter 'parent' contains file '{path.as_posix()}' already.")
self._parent = parent
self._name = name
self._directory = directory
self._sha1 = sha1
self._mode = mode
self._lastLine = lastLine
self._result = result
if parent is not None:
parent._files[self.Path] = self
@readonly
def Parent(self) -> Nullable[ParentType]:
"""
Read-only property to access the report the file belongs to (:attr:`_parent`).
:returns: The :class:`Report` or :class:`MergedReport`; ``None`` if the file belongs to no report.
"""
return self._parent
@readonly
def Name(self) -> Path:
"""
Read-only property to access the file's name, as given to the analysis (:attr:`_name`).
A file analyzed as ``ghdl -a src/Counter.vhdl`` is named ``src/Counter.vhdl``.
:returns: The name.
"""
return self._name
@readonly
def Directory(self) -> Path:
"""
Read-only property to access the directory the file was analyzed in (:attr:`_directory`).
:returns: The directory; ``.`` for the directory GHDL ran in and for an absolute name.
"""
return self._directory
@readonly
def Path(self) -> Path:
"""
Read-only property to return the file's path: its :attr:`Name`, prefixed by its :attr:`Directory` unless ``.``.
:returns: The path, as ``ghdl coverage`` names the file.
"""
return self._directory / self._name
@readonly
def SHA1(self) -> str:
"""
Read-only property to access the SHA-1 checksum of the file's content (:attr:`_sha1`).
:returns: The checksum, 40 hexadecimal digits.
"""
return self._sha1
@readonly
def Mode(self) -> CoverageMode:
"""
Read-only property to access the kind of coverage (:attr:`_mode`).
GHDL writes only statement coverage (:attr:`CoverageMode.Statement`).
:returns: The kind of coverage.
"""
return self._mode
@readonly
def LastLine(self) -> int:
"""
Read-only property to access the last line with a coverage point (:attr:`_lastLine`).
:returns: The line number.
"""
return self._lastLine
@readonly
def Result(self) -> dict[int, bool]:
"""
Read-only property to access whether each line with a coverage point ran (:attr:`_result`).
:returns: Per line number, ``True`` if a statement of the line ran.
"""
return self._result
[docs]
@export
class Report(metaclass=ExtendedType, mixin=True):
"""
The coverage file's root: the format's version, when it was written, and the source files.
"""
_version: Nullable[FormatVersion] #: Version of the format.
_testcase: Nullable[str] #: Name of the testcase.
_timestamp: Nullable[datetime] #: Time the file was written, UTC.
_files: dict[Path, File[Report]] #: The source files, by path.
[docs]
def __init__(self) -> None:
"""
Initialize an empty report.
"""
self._version = None
self._testcase = None
self._timestamp = None
self._files = {}
@readonly
def Version(self) -> Nullable[FormatVersion]:
"""
Read-only property to access the version of the format (:attr:`_version`).
:returns: The version; ``None`` before the coverage file was converted.
"""
return self._version
@readonly
def Testcase(self) -> Nullable[str]:
"""
Read-only property to access the name of the testcase (:attr:`_testcase`). GHDL writes ``unknown``.
:returns: The name; ``None`` before the coverage file was converted.
"""
return self._testcase
@readonly
def Timestamp(self) -> Nullable[datetime]:
"""
Read-only property to access the time the coverage file was written (:attr:`_timestamp`).
GHDL states it in UTC, to the millisecond, as ``YYYYMMDDhhmmss.mmm``.
:returns: The time, with time zone UTC; ``None`` before the coverage file was converted.
"""
return self._timestamp
@readonly
def Files(self) -> dict[Path, File[Report]]:
"""
Read-only property to access the source files (:attr:`_files`).
:returns: The files, by :attr:`File.Path`.
"""
return self._files
[docs]
@export
class Document(cc_Document, Report):
"""
A GHDL coverage file: read into the format's model, and converted to the common model.
"""
_jsonDocument: Nullable[dict[str, Any]] #: The parsed and validated JSON document, after :meth:`Analyze`.
[docs]
def __init__(self, jsonReportFile: Path, analyzeAndConvert: bool = False) -> None:
"""
Initialize the report, and optionally read it.
:param jsonReportFile: Path to the JSON file.
:param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
"""
super().__init__(jsonReportFile)
Report.__init__(self)
self._jsonDocument = None
if analyzeAndConvert:
self.Analyze()
self.Convert()
[docs]
def Analyze(self) -> None:
"""
Parse the JSON file, read its format version and validate it against the version's JSON Schema in :data:`SCHEMAS`.
:raises CodeCoverageError: If the file doesn't exist.
:raises CodeCoverageError: If the file can't be read.
:raises CodeCoverageError: If the file isn't UTF-8 encoded.
:raises CodeCoverageError: If the file isn't valid JSON.
:raises CodeCoverageError: If the file states no format version. |br|
The note lists the supported format versions.
:raises CodeCoverageError: If the file states an unsupported format version. |br|
The note lists the supported format versions.
:raises CodeCoverageError: If the JSON Schema can't be read.
:raises CodeCoverageError: If the file isn't valid according to the JSON Schema.
"""
if not self._path.exists():
raise CodeCoverageError(f"GHDL coverage file '{self._path}' does not exist.") \
from FileNotFoundError(f"File '{self._path}' not found.")
from jsonschema import Draft202012Validator
with Stopwatch() as sw:
try:
content = self._path.read_text(encoding="utf-8")
except OSError as ex:
raise CodeCoverageError(f"Couldn't read GHDL coverage file '{self._path}'.") from ex
except UnicodeDecodeError as ex:
raise CodeCoverageError(f"GHDL coverage file '{self._path}' is not UTF-8 encoded.") from ex
try:
jsonDocument = loads(content)
except JSONDecodeError as ex:
raise CodeCoverageError(f"JSON syntax error in GHDL coverage file '{self._path}'.") from ex
version = jsonDocument.get("version", None) if isinstance(jsonDocument, dict) else None
try:
formatVersion = FormatVersion.Parse(version)
except (TypeError, ValueError) as cause:
ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states unsupported format version '{version}'.")
ex.add_note(f"Supported format versions: {', '.join(FormatVersion)}.")
raise ex from cause
if formatVersion is None:
ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states no format version.")
ex.add_note(f"Supported format versions: {', '.join(FormatVersion)}.")
raise ex
schemaFile = SCHEMAS[formatVersion]
try:
schema = loads(readResourceFile(Resources, schemaFile))
except (ToolingException, JSONDecodeError) as ex:
raise CodeCoverageError(f"Couldn't read JSON Schema '{schemaFile}' from package resources.") from ex
errors = sorted(Draft202012Validator(schema).iter_errors(jsonDocument), key=lambda error: list(error.path))
if len(errors) > 0:
ex = CodeCoverageError(f"Validation error for '{self._path}' using JSON Schema '{schemaFile}'.")
for error in errors:
ex.add_note(f"/{'/'.join(str(part) for part in error.path)}: {error.message}")
raise ex
self._jsonDocument = jsonDocument
self._analysisDuration = sw.Duration
[docs]
def Convert(self) -> None:
"""
Convert the parsed and validated JSON document to the format's model.
:raises CodeCoverageError: If the JSON file was not analyzed before. |br|
Call 'Document.Analyze()' or create the document using
'Document(path, analyzeAndConvert=True)'.
:raises CodeCoverageError: If the timestamp isn't a valid date and time.
:raises CodeCoverageError: If the JSON file names a source file twice.
:raises CodeCoverageError: If a source file's result names a line beyond its ``max-line``.
"""
if self._jsonDocument is None:
ex = CodeCoverageError(f"GHDL coverage file '{self._path}' needs to be read and analyzed by a JSON parser.")
ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
raise ex
with Stopwatch() as sw:
try:
timestamp = datetime.strptime(self._jsonDocument["timestamp"], "%Y%m%d%H%M%S.%f")
except ValueError as cause:
ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states a malformed timestamp.")
ex.add_note(f"Got value '{self._jsonDocument['timestamp']}' at '/timestamp'.")
raise ex from cause
self._version = FormatVersion.Parse(self._jsonDocument["version"])
self._testcase = self._jsonDocument["testcase"]
self._timestamp = timestamp.replace(tzinfo=timezone.utc)
for output in self._jsonDocument["outputs"]:
name = Path(output["file"].replace("\\", "/"))
directory = Path(output["dir"].replace("\\", "/"))
lastLine = output["max-line"]
result = {int(number): ran == 1 for number, ran in output["result"].items()}
if (path := directory / name) in self._files:
raise CodeCoverageError(f"GHDL coverage file '{self._path}' names source file '{path.as_posix()}' twice.")
elif (line := max(result)) > lastLine:
ex = CodeCoverageError(
f"GHDL coverage file '{self._path}' names a line of '{path.as_posix()}' beyond 'max-line'."
)
ex.add_note(f"Got line {line} for 'max-line' {lastLine}.")
raise ex
File(name, directory, output["sha1"], CoverageMode.Parse(output["mode"]), lastLine, result, parent=self)
self._conversionDuration = sw.Duration
[docs]
def ToCoverageSummary(self) -> CoverageSummary:
"""
Convert the format's model to the common model, and aggregate it.
:returns: The report's root of the common model, named after the coverage file.
:raises CodeCoverageError: If a file's path runs through another file.
"""
return MergedReport(self._path.stem, (self, )).ToCoverageSummary()
[docs]
@export
class MergedReport(metaclass=ExtendedType, slots=True):
"""
The coverage files of several simulation runs, merged: a line ran, if it ran in one of the runs.
``ghdl coverage`` reads several coverage files too, but a line's result there is the result of the last file naming
the line.
"""
_name: str #: Name of the merged report.
_reports: list[Report] #: The merged reports, in the order they were merged.
_files: dict[Path, File[MergedReport]] #: The merged source files, by path.
[docs]
def __init__(self, name: str, reports: Nullable[Iterable[Report]] = None) -> None:
"""
Initialize a merged report, and merge the given reports.
:param name: Name of the merged report, e.g. of the design simulated.
:param reports: Optional, the reports to merge. Default: ``None``.
:raises ValueError: If parameter ``name`` is ``None``.
:raises TypeError: If parameter ``name`` isn't of type :class:`str`.
:raises ValueError: If parameter ``name`` is empty.
:raises TypeError: If parameter ``reports`` isn't iterable.
:raises TypeError: If parameter ``reports`` contains an element not of type :class:`Report`.
:raises CodeCoverageError: If a source file's checksum differs between two reports.
"""
if name is None:
raise ValueError("Parameter 'name' is None.")
elif not isinstance(name, str):
ex = TypeError("Parameter 'name' is not of type 'str'.")
ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
raise ex
elif name == "":
raise ValueError("Parameter 'name' is empty.")
self._name = name
self._reports = []
self._files = {}
if reports is not None:
if not isinstance(reports, Iterable):
ex = TypeError("Parameter 'reports' is not iterable.")
ex.add_note(f"Got type '{getFullyQualifiedName(reports)}'.")
raise ex
for report in reports:
if not isinstance(report, Report):
ex = TypeError("Parameter 'reports' contains an element not of type 'Report'.")
ex.add_note(f"Got type '{getFullyQualifiedName(report)}'.")
raise ex
self.Merge(report)
[docs]
def Merge(self, report: Report) -> None:
"""
Merge a report: add its source files, or merge them into the files of the same path.
A merged line ran, if it ran in one of the reports; a merged file's :attr:`File.LastLine` is the largest.
:param report: The report to merge.
:raises ValueError: If parameter ``report`` is ``None``.
:raises TypeError: If parameter ``report`` isn't of type :class:`Report`.
:raises CodeCoverageError: If a source file's checksum differs from the checksum in a report merged before.
"""
if report is None:
raise ValueError("Parameter 'report' is None.")
elif not isinstance(report, Report):
ex = TypeError("Parameter 'report' is not of type 'Report'.")
ex.add_note(f"Got type '{getFullyQualifiedName(report)}'.")
raise ex
for path, file in report._files.items():
if (merged := self._files.get(path)) is None:
File(
file._name, file._directory, file._sha1, file._mode, file._lastLine, dict(file._result), parent=self
)
elif merged._sha1 != file._sha1:
ex = CodeCoverageError(f"Content of source file '{path.as_posix()}' differs from the reports merged before.")
ex.add_note(f"Got SHA-1 checksum '{file._sha1}' instead of '{merged._sha1}'.")
raise ex
else:
merged._lastLine = max(merged._lastLine, file._lastLine)
result = merged._result
for number, ran in file._result.items():
result[number] = ran or result.get(number, False)
self._reports.append(report)
@readonly
def Name(self) -> str:
"""
Read-only property to access the name of the merged report (:attr:`_name`).
:returns: The name.
"""
return self._name
@readonly
def Reports(self) -> list[Report]:
"""
Read-only property to access the merged reports (:attr:`_reports`).
:returns: The reports, in the order they were merged.
"""
return self._reports
@readonly
def Files(self) -> dict[Path, File[MergedReport]]:
"""
Read-only property to access the merged source files (:attr:`_files`).
:returns: The files, by :attr:`File.Path`.
"""
return self._files
[docs]
def ToCoverageSummary(self) -> CoverageSummary:
"""
Convert the merged source files to the common model, and aggregate it.
:returns: The report's root of the common model, named after the merged report.
:raises CodeCoverageError: If a file's path runs through another file.
"""
summary = CoverageSummary(self._name)
for path, file in self._files.items():
commonFile = summary.GetOrAddFile(path)
result = file._result
for number in sorted(result):
cc_Line(
number,
LineCoverageStatus.Covered if result[number] else LineCoverageStatus.Uncovered,
parent=commonFile
)
summary.Aggregate()
return summary