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

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

78 

79from pyEDAA.Reports import Resources 

80from pyEDAA.Reports.CodeCoverage import CodeCoverageError, CoverageSummary, Document as cc_Document, Line as cc_Line, \ 

81 LineCoverageStatus 

82 

83 

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

85 

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 

89 

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

91 

92 

93@export 

94class FormatVersion(StringEnum): 

95 """ 

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

97 

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

99 """ 

100 

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

102 

103 

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. 

107 

108 

109@export 

110class CoverageMode(StringEnum): 

111 """ 

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

113 

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

117 

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

119 

120 

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

126 

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. 

134 

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. 

148 

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 

184 

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 

191 

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 

202 

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 

209 

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 

220 

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 

227 

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 

245 

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

253 

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 

261 

262 if parent is not None: 

263 parent._files[self.Path] = self 

264 

265 @readonly 

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

267 """ 

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

269 

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

271 """ 

272 return self._parent 

273 

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

278 

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

280 

281 :returns: The name. 

282 """ 

283 return self._name 

284 

285 @readonly 

286 def Directory(self) -> Path: 

287 """ 

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

289 

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

291 """ 

292 return self._directory 

293 

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

298 

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

300 """ 

301 return self._directory / self._name 

302 

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

307 

308 :returns: The checksum, 40 hexadecimal digits. 

309 """ 

310 return self._sha1 

311 

312 @readonly 

313 def Mode(self) -> CoverageMode: 

314 """ 

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

316 

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

318 

319 :returns: The kind of coverage. 

320 """ 

321 return self._mode 

322 

323 @readonly 

324 def LastLine(self) -> int: 

325 """ 

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

327 

328 :returns: The line number. 

329 """ 

330 return self._lastLine 

331 

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

336 

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

338 """ 

339 return self._result 

340 

341 

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

347 

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. 

352 

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

361 

362 @readonly 

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

364 """ 

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

366 

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

368 """ 

369 return self._version 

370 

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

375 

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

377 """ 

378 return self._testcase 

379 

380 @readonly 

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

382 """ 

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

384 

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

386 

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

388 """ 

389 return self._timestamp 

390 

391 @readonly 

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

393 """ 

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

395 

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

397 """ 

398 return self._files 

399 

400 

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

406 

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

408 

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

410 """ 

411 Initialize the report, and optionally read it. 

412 

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) 

418 

419 self._jsonDocument = None 

420 

421 if analyzeAndConvert: 

422 self.Analyze() 

423 self.Convert() 

424 

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

428 

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

443 

444 from jsonschema import Draft202012Validator 

445 

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 

453 

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 

458 

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 

466 

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 

471 

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 

477 

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 

484 

485 self._jsonDocument = jsonDocument 

486 

487 self._analysisDuration = sw.Duration 

488 

489 def Convert(self) -> None: 

490 """ 

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

492 

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 

504 

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 

512 

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

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

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

516 

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

522 

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 

531 

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

533 

534 self._conversionDuration = sw.Duration 

535 

536 def ToCoverageSummary(self) -> CoverageSummary: 

537 """ 

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

539 

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

544 

545 

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. 

550 

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

554 

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. 

558 

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

560 """ 

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

562 

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

580 

581 self._name = name 

582 self._reports = [] 

583 self._files = {} 

584 

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 

590 

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 

596 

597 self.Merge(report) 

598 

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. 

602 

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

604 

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 

616 

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) 

631 

632 self._reports.append(report) 

633 

634 @readonly 

635 def Name(self) -> str: 

636 """ 

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

638 

639 :returns: The name. 

640 """ 

641 return self._name 

642 

643 @readonly 

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

645 """ 

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

647 

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

649 """ 

650 return self._reports 

651 

652 @readonly 

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

654 """ 

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

656 

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

658 """ 

659 return self._files 

660 

661 def ToCoverageSummary(self) -> CoverageSummary: 

662 """ 

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

664 

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) 

669 

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 ) 

679 

680 summary.Aggregate() 

681 return summary