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

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. 

34 

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. 

40 

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`: 

44 

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. 

50 

51.. rubric:: Example 

52 

53.. code-block:: Python 

54 

55 from pathlib import Path 

56 from pyEDAA.Reports.CodeCoverage.GHDL import Document, MergedReport 

57 

58 documents = [] 

59 for path in sorted(Path(".").glob("coverage-*.json")): 

60 documents.append(Document(path, analyzeAndConvert=True)) 

61 

62 summary = MergedReport("Counter", documents).ToCoverageSummary() 

63 print(f"{summary.FileCount} files: {summary.LineCoverage:.1%}") 

64""" 

65from __future__ import annotations 

66 

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 

72 

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 

79 

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 

83 

84 

85__all__ = ["SCHEMAS", "ParentType"] 

86 

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 

90 

91ParentType = TypeVar("ParentType", bound="Report | MergedReport") #: The report a :class:`File` belongs to. 

92 

93 

94@export 

95class FormatVersion(StringEnum): 

96 """ 

97 Version of GHDL's coverage format, as a coverage file's ``version`` states it. 

98 

99 GHDL's writer :file:`src/ghdldrv/ghdlcovout.adb` writes only version ``1.0.0``. 

100 """ 

101 

102 Version1_0_0 = "1.0.0" #: Format version 1.0.0. 

103 

104 

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. 

108 

109 

110@export 

111class CoverageMode(StringEnum): 

112 """ 

113 Kind of coverage of a source file, as stated by an entry's ``mode``. 

114 

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 """ 

118 

119 Statement = "stmt" #: Statement coverage: per line with a statement, whether a statement of the line ran. 

120 

121 

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 """ 

127 

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. 

135 

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. 

149 

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 

185 

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 

192 

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 

203 

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 

210 

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 

221 

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 

228 

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 

246 

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.") 

254 

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 

262 

263 if parent is not None: 

264 parent._files[self.Path] = self 

265 

266 @readonly 

267 def Parent(self) -> Nullable[ParentType]: 

268 """ 

269 Read-only property to access the report the file belongs to (:attr:`_parent`). 

270 

271 :returns: The :class:`Report` or :class:`MergedReport`; ``None`` if the file belongs to no report. 

272 """ 

273 return self._parent 

274 

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`). 

279 

280 A file analyzed as ``ghdl -a src/Counter.vhdl`` is named ``src/Counter.vhdl``. 

281 

282 :returns: The name. 

283 """ 

284 return self._name 

285 

286 @readonly 

287 def Directory(self) -> Path: 

288 """ 

289 Read-only property to access the directory the file was analyzed in (:attr:`_directory`). 

290 

291 :returns: The directory; ``.`` for the directory GHDL ran in and for an absolute name. 

292 """ 

293 return self._directory 

294 

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 ``.``. 

299 

300 :returns: The path, as ``ghdl coverage`` names the file. 

301 """ 

302 return self._directory / self._name 

303 

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`). 

308 

309 :returns: The checksum, 40 hexadecimal digits. 

310 """ 

311 return self._sha1 

312 

313 @readonly 

314 def Mode(self) -> CoverageMode: 

315 """ 

316 Read-only property to access the kind of coverage (:attr:`_mode`). 

317 

318 GHDL writes only statement coverage (:attr:`CoverageMode.Statement`). 

319 

320 :returns: The kind of coverage. 

321 """ 

322 return self._mode 

323 

324 @readonly 

325 def LastLine(self) -> int: 

326 """ 

327 Read-only property to access the last line with a coverage point (:attr:`_lastLine`). 

328 

329 :returns: The line number. 

330 """ 

331 return self._lastLine 

332 

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`). 

337 

338 :returns: Per line number, ``True`` if a statement of the line ran. 

339 """ 

340 return self._result 

341 

342 

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 """ 

348 

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. 

353 

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 = {} 

362 

363 @readonly 

364 def Version(self) -> Nullable[FormatVersion]: 

365 """ 

366 Read-only property to access the version of the format (:attr:`_version`). 

367 

368 :returns: The version; ``None`` before the coverage file was converted. 

369 """ 

370 return self._version 

371 

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``. 

376 

377 :returns: The name; ``None`` before the coverage file was converted. 

378 """ 

379 return self._testcase 

380 

381 @readonly 

382 def Timestamp(self) -> Nullable[datetime]: 

383 """ 

384 Read-only property to access the time the coverage file was written (:attr:`_timestamp`). 

385 

386 GHDL states it in UTC, to the millisecond, as ``YYYYMMDDhhmmss.mmm``. 

387 

388 :returns: The time, with time zone UTC; ``None`` before the coverage file was converted. 

389 """ 

390 return self._timestamp 

391 

392 @readonly 

393 def Files(self) -> dict[Path, File[Report]]: 

394 """ 

395 Read-only property to access the source files (:attr:`_files`). 

396 

397 :returns: The files, by :attr:`File.Path`. 

398 """ 

399 return self._files 

400 

401 

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 """ 

407 

408 _jsonDocument: Nullable[dict[str, Any]] #: The parsed and validated JSON document, after :meth:`Analyze`. 

409 

410 def __init__(self, jsonReportFile: Path, analyzeAndConvert: bool = False) -> None: 

411 """ 

412 Initialize the report, and optionally read it. 

413 

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) 

419 

420 self._jsonDocument = None 

421 

422 if analyzeAndConvert: 

423 self.Analyze() 

424 self.Convert() 

425 

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`. 

429 

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.") 

444 

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 

452 

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 

457 

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 

465 

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 

470 

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 

476 

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 

483 

484 self._jsonDocument = jsonDocument 

485 

486 self._analysisDuration = sw.Duration 

487 

488 def Convert(self) -> None: 

489 """ 

490 Convert the parsed and validated JSON document to the format's model. 

491 

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 

503 

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 

511 

512 self._version = FormatVersion.Parse(self._jsonDocument["version"]) 

513 self._testcase = self._jsonDocument["testcase"] 

514 self._timestamp = timestamp.replace(tzinfo=timezone.utc) 

515 

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()} 

521 

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 

530 

531 File(name, directory, output["sha1"], CoverageMode.Parse(output["mode"]), lastLine, result, parent=self) 

532 

533 self._conversionDuration = sw.Duration 

534 

535 def ToCoverageSummary(self) -> CoverageSummary: 

536 """ 

537 Convert the format's model to the common model, and aggregate it. 

538 

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() 

543 

544 

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. 

549 

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 """ 

553 

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. 

557 

558 def __init__(self, name: str, reports: Nullable[Iterable[Report]] = None) -> None: 

559 """ 

560 Initialize a merged report, and merge the given reports. 

561 

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.") 

579 

580 self._name = name 

581 self._reports = [] 

582 self._files = {} 

583 

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 

589 

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 

595 

596 self.Merge(report) 

597 

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. 

601 

602 A merged line ran, if it ran in one of the reports; a merged file's :attr:`File.LastLine` is the largest. 

603 

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 

615 

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) 

630 

631 self._reports.append(report) 

632 

633 @readonly 

634 def Name(self) -> str: 

635 """ 

636 Read-only property to access the name of the merged report (:attr:`_name`). 

637 

638 :returns: The name. 

639 """ 

640 return self._name 

641 

642 @readonly 

643 def Reports(self) -> list[Report]: 

644 """ 

645 Read-only property to access the merged reports (:attr:`_reports`). 

646 

647 :returns: The reports, in the order they were merged. 

648 """ 

649 return self._reports 

650 

651 @readonly 

652 def Files(self) -> dict[Path, File[MergedReport]]: 

653 """ 

654 Read-only property to access the merged source files (:attr:`_files`). 

655 

656 :returns: The files, by :attr:`File.Path`. 

657 """ 

658 return self._files 

659 

660 def ToCoverageSummary(self) -> CoverageSummary: 

661 """ 

662 Convert the merged source files to the common model, and aggregate it. 

663 

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) 

668 

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 ) 

678 

679 summary.Aggregate() 

680 return summary