Coverage for pyEDAA/Reports/CodeCoverage/Cobertura/__init__.py: 98%

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

32The Cobertura XML code coverage format: a model of the format, read from a report and converted to the common model, or 

33converted from the common model and written to a report. 

34 

35Cobertura's XML format is written by many tools - e.g. coverage.py (``coverage xml``) or gcovr (``--cobertura``) - and 

36read by many CI services. The format's model follows Cobertura's DTD ``coverage-04.dtd``: a :class:`Document` holds 

37:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Package` elements, a package 

38:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Class` elements, a class 

39:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Method` elements and 

40:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Line` elements, and a line 

41:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Condition` elements; each keeps the attributes the report states, 

42e.g. its rates. The elements below the root element ``<coverage>`` are in 

43:mod:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records`. 

44 

45A report is validated against :file:`Any-Cobertura.xsd`, which accepts the attributes tools add. 

46:file:`Cobertura-04.xsd` is the DTD's strict translation to XML Schema. 

47 

48A dialect - the format as one tool writes it - has a module of its own in this package: a :class:`Document` derived 

49from this one, validating against a strict XML schema of the dialect, and reading what the dialect adds. 

50 

51.. seealso:: 

52 

53 :mod:`~pyEDAA.Reports.CodeCoverage.Cobertura.CoveragePyCobertura` 

54 |rarr| coverage.py's dialect (``coverage xml``). 

55 

56 :mod:`~pyEDAA.Reports.CodeCoverage.Cobertura.NVCCobertura` 

57 |rarr| NVC's dialect (``nvc --cover-export --format=cobertura``). 

58 

59:meth:`Document.ToCoverageSummary` converts the model to the common model of :mod:`pyEDAA.Reports.CodeCoverage`: 

60 

61* A class' ``filename`` is a file's path, relative to one of the ``<source>`` directories; several classes of one file 

62 - e.g. Java's nested classes - are one file, their lines merged. 

63* A line's ``hits`` is its count; a branching line's ``condition-coverage`` - e.g. ``50% (1/2)`` - states its taken and 

64 all branches, which become as many branches without count. 

65* The packages, classes and methods become units of the logical hierarchy; a package's name is split at ``.`` into 

66 nested packages. A class or method spans its file from the first to the last line it lists. 

67* The format has no excluded lines. 

68 

69:meth:`Document.FromCoverageSummary` converts the common model to the format's model, which :meth:`Document.Write` 

70writes following the DTD: 

71 

72* Every source file, module and class of the logical hierarchy becomes a ``<class>`` of its file, named by its 

73 qualified name below its packages; its packages - or the file's directories, if it has none - name its 

74 ``<package>``. A file's line is listed by the innermost of them spanning it, a line outside of every one by a 

75 ``<class>`` named after the file. 

76* Every function and method becomes a ``<method>`` of the ``<class>`` of the unit containing it, listing its lines. 

77* A line's count is its ``hits`` - ``1`` or ``0``, if the report didn't say -; a branching line states its taken and 

78 all branches in ``condition-coverage``. Excluded lines are left out. 

79 

80.. rubric:: Example 

81 

82.. code-block:: Python 

83 

84 from pathlib import Path 

85 from pyEDAA.Reports.CodeCoverage.Cobertura import Document 

86 

87 report = Document(Path("coverage.xml"), analyzeAndConvert=True) 

88 summary = report.ToCoverageSummary() 

89 for file in summary.IterateFiles(): 

90 print(f"{file.Path}: {file.LineCoverage:.1%}") 

91 

92 Document.FromCoverageSummary(Path("Cobertura.xml"), summary).Write(regenerate=True) 

93""" 

94from __future__ import annotations 

95 

96from io import BytesIO 

97from pathlib import Path 

98from re import compile as re_compile 

99from time import time_ns 

100from typing import Optional as Nullable 

101 

102from lxml.etree import Element as XMLElement, ElementTree, SubElement, XMLParser 

103from lxml.etree import XMLSchema, XMLSchemaParseError, XMLSyntaxError, parse 

104from lxml.etree import tostring, _Element, _ElementTree 

105from pyTooling.Common import getFullyQualifiedName, getResourceFile 

106from pyTooling.Decorators import export, readonly 

107from pyTooling.Exceptions import ToolingException 

108from pyTooling.MetaClasses import ExtendedType 

109from pyTooling.Stopwatch import Stopwatch 

110 

111from pyEDAA.Reports import Resources, __version__ 

112from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, Class as cc_Class, CodeCoverageError 

113from pyEDAA.Reports.CodeCoverage import CoverageSummary, Document as cc_Document, File as cc_File 

114from pyEDAA.Reports.CodeCoverage import Line as cc_Line, LineCoverageStatus, Method as cc_Method 

115from pyEDAA.Reports.CodeCoverage import Package as cc_Package, Unit as cc_Unit 

116from pyEDAA.Reports.CodeCoverage.Cobertura.Records import Class, Condition, Element, Line, Method, Package 

117 

118 

119__all__ = ["READ_SCHEMA", "STRICT_SCHEMA", "CONDITION_COVERAGE"] 

120 

121READ_SCHEMA = "Any-Cobertura.xsd" #: The XML schema a report is validated against when read: lenient. 

122STRICT_SCHEMA = "Cobertura-04.xsd" #: The XML schema translating Cobertura's DTD ``coverage-04.dtd``: strict. 

123 

124#: Pattern of attribute ``condition-coverage``: the taken and all branches, e.g. ``(1/2)`` of ``50% (1/2)``. 

125CONDITION_COVERAGE = re_compile(r"\((\d+)/(\d+)\)$") 

126 

127 

128def _float(element: _Element, name: str) -> Nullable[float]: 

129 """ 

130 Read an attribute holding a decimal number. 

131 

132 :param element: The element. 

133 :param name: The attribute's name. 

134 :returns: The number, or ``None`` if the element has no such attribute. 

135 """ 

136 return None if (value := element.attrib.get(name)) is None else float(value) 

137 

138 

139def _int(element: _Element, name: str) -> Nullable[int]: 

140 """ 

141 Read an attribute holding an integer. 

142 

143 :param element: The element. 

144 :param name: The attribute's name. 

145 :returns: The number, or ``None`` if the element has no such attribute. 

146 """ 

147 return None if (value := element.attrib.get(name)) is None else int(value) 

148 

149 

150@export 

151class Coverage(metaclass=ExtendedType, mixin=True): 

152 """ 

153 The root element ``<coverage>``: the report's source directories, packages and figures, as the report states them. 

154 """ 

155 

156 _version: Nullable[str] #: Version of the tool, which wrote the report. 

157 _timestamp: Nullable[str] #: Time the report was written, as the report states it. 

158 _lineRate: Nullable[float] #: Line coverage, as the report states it. 

159 _branchRate: Nullable[float] #: Branch coverage, as the report states it. 

160 _linesCovered: Nullable[int] #: Number of covered lines, as the report states it. 

161 _linesValid: Nullable[int] #: Number of executable lines, as the report states it. 

162 _branchesCovered: Nullable[int] #: Number of taken branches, as the report states it. 

163 _branchesValid: Nullable[int] #: Number of branches, as the report states it. 

164 _complexity: Nullable[float] #: Complexity, as the report states it. 

165 _sources: list[str] #: The ``<source>`` directories. 

166 _packages: list[Package] #: The packages, in the report's order. 

167 

168 def __init__(self) -> None: 

169 """ 

170 Initialize an empty report. 

171 """ 

172 self._version = None 

173 self._timestamp = None 

174 self._lineRate = None 

175 self._branchRate = None 

176 self._linesCovered = None 

177 self._linesValid = None 

178 self._branchesCovered = None 

179 self._branchesValid = None 

180 self._complexity = None 

181 self._sources = [] 

182 self._packages = [] 

183 

184 @readonly 

185 def Version(self) -> Nullable[str]: 

186 """ 

187 Read-only property to access the version of the tool, which wrote the report (:attr:`_version`). 

188 

189 The version is stated as the report states it, e.g. ``gcovr 8.4``. 

190 

191 :returns: The version, or ``None`` if the report doesn't state it. 

192 """ 

193 return self._version 

194 

195 @readonly 

196 def Timestamp(self) -> Nullable[str]: 

197 """ 

198 Read-only property to access the time the report was written, as the report states it (:attr:`_timestamp`). 

199 

200 The unit depends on the tool, e.g. milliseconds or seconds since the epoch. 

201 

202 :returns: The timestamp, or ``None``. 

203 """ 

204 return self._timestamp 

205 

206 @readonly 

207 def LineRate(self) -> Nullable[float]: 

208 """ 

209 Read-only property to access the line coverage, as the report states it (:attr:`_lineRate`). 

210 

211 :returns: The rate in range 0.0..1.0, or ``None`` if the report doesn't state it. 

212 """ 

213 return self._lineRate 

214 

215 @readonly 

216 def BranchRate(self) -> Nullable[float]: 

217 """ 

218 Read-only property to access the branch coverage, as the report states it (:attr:`_branchRate`). 

219 

220 :returns: The rate in range 0.0..1.0, or ``None`` if the report doesn't state it. 

221 """ 

222 return self._branchRate 

223 

224 @readonly 

225 def LinesCovered(self) -> Nullable[int]: 

226 """ 

227 Read-only property to access the number of covered lines, as the report states it (:attr:`_linesCovered`). 

228 

229 :returns: The number, or ``None`` if the report doesn't state it. 

230 """ 

231 return self._linesCovered 

232 

233 @readonly 

234 def LinesValid(self) -> Nullable[int]: 

235 """ 

236 Read-only property to access the number of executable lines, as the report states it (:attr:`_linesValid`). 

237 

238 :returns: The number, or ``None`` if the report doesn't state it. 

239 """ 

240 return self._linesValid 

241 

242 @readonly 

243 def BranchesCovered(self) -> Nullable[int]: 

244 """ 

245 Read-only property to access the number of taken branches, as the report states it (:attr:`_branchesCovered`). 

246 

247 :returns: The number, or ``None`` if the report doesn't state it. 

248 """ 

249 return self._branchesCovered 

250 

251 @readonly 

252 def BranchesValid(self) -> Nullable[int]: 

253 """ 

254 Read-only property to access the number of branches, as the report states it (:attr:`_branchesValid`). 

255 

256 :returns: The number, or ``None`` if the report doesn't state it. 

257 """ 

258 return self._branchesValid 

259 

260 @readonly 

261 def Complexity(self) -> Nullable[float]: 

262 """ 

263 Read-only property to access the complexity, as the report states it (:attr:`_complexity`). 

264 

265 :returns: The complexity, or ``None`` if the report doesn't state it. 

266 """ 

267 return self._complexity 

268 

269 @readonly 

270 def Sources(self) -> list[str]: 

271 """ 

272 Read-only property to access the ``<source>`` directories (:attr:`_sources`). 

273 

274 :returns: The directories, as the report states them. 

275 """ 

276 return self._sources 

277 

278 @readonly 

279 def Packages(self) -> list[Package]: 

280 """ 

281 Read-only property to access the packages (:attr:`_packages`). 

282 

283 :returns: The packages, in the report's order. 

284 """ 

285 return self._packages 

286 

287 

288@export 

289class Document(cc_Document, Coverage): 

290 """ 

291 A Cobertura XML code coverage report: read into the format's model and converted to the common model, or converted 

292 from the common model and written. 

293 """ 

294 

295 _xmlDocument: Nullable[_ElementTree] #: The XML document, parsed by :meth:`Analyze` or built by :meth:`Generate`. 

296 

297 def __init__(self, xmlReportFile: Path, analyzeAndConvert: bool = False) -> None: 

298 """ 

299 Initialize the report, and optionally read it. 

300 

301 :param xmlReportFile: Path to the Cobertura XML file. 

302 :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``. 

303 """ 

304 super().__init__(xmlReportFile) 

305 Coverage.__init__(self) 

306 

307 self._xmlDocument = None 

308 

309 if analyzeAndConvert: 

310 self.Analyze() 

311 self.Convert() 

312 

313 @classmethod 

314 def FromCoverageSummary(cls, xmlReportFile: Path, coverageSummary: CoverageSummary) -> Document: 

315 """ 

316 Create a report from the common model, ready to be generated and written. 

317 

318 Every source file, module and class of the logical hierarchy becomes a 

319 :class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Class` of its file, named by its qualified name below its 

320 packages; its packages - or the file's directories, if it has none - name its 

321 :class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Package`. A file's line is listed by the innermost of these 

322 classes spanning it; a line outside of every one by a class named after the file. Every function and method becomes 

323 a :class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Method` of the class of the unit containing it. Excluded 

324 lines are left out. The rates and figures are computed from the lines; the version names pyEDAA.Reports, the 

325 timestamp is the current time in milliseconds since the epoch. 

326 

327 :param xmlReportFile: Path to the Cobertura XML file to write. 

328 :param coverageSummary: The report's root of the common model. 

329 :returns: The report. 

330 :raises ValueError: If parameter ``xmlReportFile`` is ``None``. 

331 :raises TypeError: If parameter ``xmlReportFile`` isn't of type :class:`~pathlib.Path`. 

332 :raises ValueError: If parameter ``coverageSummary`` is ``None``. 

333 :raises TypeError: If parameter ``coverageSummary`` isn't of type 

334 :class:`~pyEDAA.Reports.CodeCoverage.CoverageSummary`. 

335 """ 

336 if xmlReportFile is None: 

337 raise ValueError("Parameter 'xmlReportFile' is None.") 

338 elif not isinstance(xmlReportFile, Path): 

339 ex = TypeError("Parameter 'xmlReportFile' is not of type 'Path'.") 

340 ex.add_note(f"Got type '{getFullyQualifiedName(xmlReportFile)}'.") 

341 raise ex 

342 

343 if coverageSummary is None: 

344 raise ValueError("Parameter 'coverageSummary' is None.") 

345 elif not isinstance(coverageSummary, CoverageSummary): 

346 ex = TypeError("Parameter 'coverageSummary' is not of type 'CoverageSummary'.") 

347 ex.add_note(f"Got type '{getFullyQualifiedName(coverageSummary)}'.") 

348 raise ex 

349 

350 document = cls(xmlReportFile) 

351 document._version = f"pyEDAA.Reports {__version__}" 

352 document._timestamp = str(time_ns() // 1_000_000) 

353 document._complexity = 0.0 

354 document._sources = [directory.as_posix() for directory in coverageSummary._sourceDirectories] 

355 document._packages = Package._FromFiles(coverageSummary.IterateFiles()) 

356 

357 counts = Element._CountLines( 

358 line for package in document._packages for klass in package._classes for line in klass._lines.values() 

359 ) 

360 document._linesValid, document._linesCovered, document._branchesValid, document._branchesCovered = counts 

361 document._lineRate, document._branchRate = Element._Rates(counts) 

362 

363 return document 

364 

365 def Analyze(self) -> None: 

366 """ 

367 Parse the XML file and validate it against the lenient XML schema :data:`READ_SCHEMA`. 

368 

369 :raises CodeCoverageError: If the file doesn't exist. 

370 :raises CodeCoverageError: If the file can't be read. 

371 :raises CodeCoverageError: If the file isn't well-formed XML. 

372 :raises CodeCoverageError: If the root element isn't ``<coverage>``. 

373 :raises CodeCoverageError: If the XML schema can't be located or parsed. 

374 :raises CodeCoverageError: If the file isn't valid according to the XML schema. 

375 """ 

376 self._Analyze(READ_SCHEMA) 

377 

378 def _Analyze(self, xmlSchemaFile: str) -> None: 

379 """ 

380 Parse the XML file and validate it against an XML schema of the package resources. 

381 

382 A dialect's :meth:`Analyze` calls it with the dialect's XML schema. 

383 

384 :param xmlSchemaFile: File name of the XML schema in :mod:`pyEDAA.Reports.Resources`. 

385 :raises CodeCoverageError: If the file doesn't exist. 

386 :raises CodeCoverageError: If the file can't be read. 

387 :raises CodeCoverageError: If the file isn't well-formed XML. 

388 :raises CodeCoverageError: If the root element isn't ``<coverage>``. 

389 :raises CodeCoverageError: If the XML schema can't be located or parsed. 

390 :raises CodeCoverageError: If the file isn't valid according to the XML schema. 

391 """ 

392 if not self._path.exists(): 

393 raise CodeCoverageError(f"Cobertura report file '{self._path}' does not exist.") \ 

394 from FileNotFoundError(f"File '{self._path}' not found.") 

395 

396 with Stopwatch() as sw: 

397 try: 

398 content = self._path.read_bytes() 

399 except OSError as ex: 

400 raise CodeCoverageError(f"Couldn't read Cobertura report file '{self._path}'.") from ex 

401 

402 try: 

403 xmlDocument = parse(BytesIO(content), XMLParser(ns_clean=True), base_url=str(self._path)) 

404 except XMLSyntaxError as ex: 

405 raise CodeCoverageError(f"XML syntax error in Cobertura report file '{self._path}'.") from ex 

406 

407 rootElement: _Element = xmlDocument.getroot() 

408 if rootElement.tag != "coverage": 

409 ex = CodeCoverageError(f"Root element of '{self._path}' is not '<coverage>'.") 

410 ex.add_note(f"Got root element '<{rootElement.tag}>'.") 

411 raise ex 

412 

413 try: 

414 schemaResourceFile = getResourceFile(Resources, xmlSchemaFile) 

415 except ToolingException as ex: 

416 raise CodeCoverageError(f"Couldn't locate XML Schema '{xmlSchemaFile}' in package resources.") from ex 

417 

418 try: 

419 xmlSchema = XMLSchema(parse(schemaResourceFile, XMLParser(ns_clean=True))) 

420 except (XMLSyntaxError, XMLSchemaParseError) as ex: 

421 raise CodeCoverageError(f"Error while parsing XML Schema '{xmlSchemaFile}'.") from ex 

422 

423 if not xmlSchema.validate(xmlDocument): 

424 ex = CodeCoverageError(f"Validation error for '{self._path}' using XSD schema '{xmlSchemaFile}'.") 

425 for logEntry in xmlSchema.error_log: 

426 ex.add_note(str(logEntry)) 

427 raise ex 

428 

429 self._xmlDocument = xmlDocument 

430 

431 self._analysisDuration = sw.Duration 

432 

433 def Convert(self) -> None: 

434 """ 

435 Convert the parsed and validated XML document to the format's model. 

436 

437 :raises CodeCoverageError: If the XML file was not analyzed before. |br| 

438 Call 'Document.Analyze()' or create the document using 

439 'Document(path, analyzeAndConvert=True)'. 

440 """ 

441 if self._xmlDocument is None: 

442 ex = CodeCoverageError(f"Cobertura report file '{self._path}' needs to be read and analyzed by an XML parser.") 

443 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.") 

444 raise ex 

445 

446 with Stopwatch() as sw: 

447 root: _Element = self._xmlDocument.getroot() 

448 

449 self._version = root.attrib.get("version") 

450 self._timestamp = root.attrib.get("timestamp") 

451 self._lineRate = _float(root, "line-rate") 

452 self._branchRate = _float(root, "branch-rate") 

453 self._linesCovered = _int(root, "lines-covered") 

454 self._linesValid = _int(root, "lines-valid") 

455 self._branchesCovered = _int(root, "branches-covered") 

456 self._branchesValid = _int(root, "branches-valid") 

457 self._complexity = _float(root, "complexity") 

458 

459 self._sources = [ 

460 element.text.strip() for element in root.iterfind("sources/source") 

461 if element.text is not None and element.text.strip() != "" 

462 ] 

463 

464 for packageElement in root.iterfind("packages/package"): # type: _Element 

465 package = Package( 

466 packageElement.attrib["name"], _float(packageElement, "line-rate"), _float(packageElement, "branch-rate"), 

467 _float(packageElement, "complexity") 

468 ) 

469 self._packages.append(package) 

470 

471 for classElement in packageElement.iterfind("classes/class"): # type: _Element 

472 package._classes.append(self._ConvertClass(classElement)) 

473 

474 self._conversionDuration = sw.Duration 

475 

476 @classmethod 

477 def _ConvertClass(cls, classElement: _Element) -> Class: 

478 """ 

479 Convert a ``<class>`` element, its methods and lines. 

480 

481 :param classElement: The ``<class>`` element. 

482 :returns: The class. 

483 """ 

484 klass = Class( 

485 classElement.attrib.get("name", classElement.attrib["filename"]), classElement.attrib["filename"], 

486 _float(classElement, "line-rate"), _float(classElement, "branch-rate"), _float(classElement, "complexity") 

487 ) 

488 

489 for methodElement in classElement.iterfind("methods/method"): # type: _Element 

490 method = Method( 

491 methodElement.attrib["name"], methodElement.attrib.get("signature"), _float(methodElement, "line-rate"), 

492 _float(methodElement, "branch-rate") 

493 ) 

494 key = method._name if method._name not in klass._methods else f"{method._name}{method._signature}" 

495 klass._methods[key] = method 

496 for lineElement in methodElement.iterfind("lines/line"): # type: _Element 

497 line = cls._ConvertLine(lineElement) 

498 method._lines[line._number] = line 

499 

500 for lineElement in classElement.iterfind("lines/line"): # type: _Element 

501 line = cls._ConvertLine(lineElement) 

502 klass._lines[line._number] = line 

503 

504 return klass 

505 

506 @staticmethod 

507 def _ConvertLine(lineElement: _Element) -> Line: 

508 """ 

509 Convert a ``<line>`` element and its conditions. 

510 

511 :param lineElement: The ``<line>`` element. 

512 :returns: The line. 

513 """ 

514 conditionCoverage = None 

515 if (match := CONDITION_COVERAGE.search(lineElement.attrib.get("condition-coverage", ""))) is not None: 

516 conditionCoverage = (int(match[1]), int(match[2])) 

517 

518 conditions = [ 

519 Condition(int(element.attrib["number"]), element.attrib.get("type", ""), element.attrib.get("coverage", "")) 

520 for element in lineElement.iterfind("conditions/condition") 

521 ] 

522 

523 return Line( 

524 int(lineElement.attrib["number"]), int(lineElement.attrib["hits"]), 

525 lineElement.attrib.get("branch", "false") in ("true", "1"), conditionCoverage, conditions 

526 ) 

527 

528 def ToCoverageSummary(self) -> CoverageSummary: 

529 """ 

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

531 

532 Several classes of one file - e.g. Java's nested classes - are one file: a line listed by several of them ran, if 

533 one of them says so, its hits are added, and of its branches the higher counts are kept. Every package, class and 

534 method becomes a unit; a class or method spans its file from its first to its last line. 

535 

536 :returns: The report's root of the common model, named after the report file. 

537 :raises CodeCoverageError: If a file's path runs through another file. 

538 """ 

539 summary = CoverageSummary(self._path.stem, sourceDirectories=[Path(source) for source in self._sources]) 

540 

541 merged: dict[int, dict[int, tuple[int, int, int]]] = {} 

542 files: dict[int, cc_File] = {} 

543 for package in self._packages: 

544 for klass in package._classes: 

545 file = summary.GetOrAddFile(klass._filename) 

546 files[id(file)] = file 

547 lines = merged.setdefault(id(file), {}) 

548 for line in klass._lines.values(): 

549 covered, total = (0, 0) if line._conditionCoverage is None else line._conditionCoverage 

550 hits, oldCovered, oldTotal = lines.get(line._number, (0, 0, 0)) 

551 lines[line._number] = (hits + line._hits, max(covered, oldCovered), max(total, oldTotal)) 

552 

553 for fileID, lines in merged.items(): 

554 file = files[fileID] 

555 for number in sorted(lines): 

556 hits, covered, total = lines[number] 

557 branches = [cc_Branch(LineCoverageStatus.Covered) for _ in range(covered)] 

558 branches.extend(cc_Branch(LineCoverageStatus.Uncovered) for _ in range(total - covered)) 

559 if hits == 0: 

560 status = LineCoverageStatus.Uncovered 

561 elif covered < total: 

562 status = LineCoverageStatus.PartiallyCovered 

563 else: 

564 status = LineCoverageStatus.Covered 

565 cc_Line(number, status, hits, branches, parent=file) 

566 

567 for package in self._packages: 

568 parent: cc_Unit | CoverageSummary = summary 

569 for part in (part for part in package._name.split(".") if part != ""): 

570 parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent) 

571 

572 for klass in package._classes: 

573 file = summary.GetOrAddFile(klass._filename) 

574 if (classUnit := parent._units.get(klass._name)) is None: 574 ↛ 584line 574 didn't jump to line 584 because the condition on line 574 was always true

575 numbers = list(klass._lines) 

576 classUnit = cc_Class( 

577 klass._name, 

578 file=file, 

579 startLine=file._lines[min(numbers)] if len(numbers) > 0 else None, 

580 endLine=file._lines[max(numbers)] if len(numbers) > 0 else None, 

581 parent=parent 

582 ) 

583 

584 for key, method in klass._methods.items(): 

585 if key not in classUnit._units: 585 ↛ 584line 585 didn't jump to line 584 because the condition on line 585 was always true

586 numbers = [ 

587 number for number in method._lines if number <= file._lastLineNumber and file._lines[number] is not None 

588 ] 

589 cc_Method( 

590 key, 

591 file=file, 

592 startLine=file._lines[min(numbers)] if len(numbers) > 0 else None, 

593 endLine=file._lines[max(numbers)] if len(numbers) > 0 else None, 

594 parent=classUnit 

595 ) 

596 

597 summary.Aggregate() 

598 return summary 

599 

600 def Generate(self, overwrite: bool = False) -> None: 

601 """ 

602 Generate the XML document from the format's model, following Cobertura's DTD. 

603 

604 A rate or figure the model doesn't state - e.g. read from a report without it - is computed from the lines; a 

605 missing complexity is written as ``0``, a missing version, timestamp or signature as empty text. 

606 

607 :param overwrite: Optional, if true, replace the XML document read or generated before. Default: ``False``. 

608 :raises ValueError: If parameter ``overwrite`` is ``None``. 

609 :raises TypeError: If parameter ``overwrite`` isn't of type :class:`bool`. 

610 :raises CodeCoverageError: If parameter ``overwrite`` is false and the XML document was read or generated before. 

611 """ 

612 if overwrite is None: 

613 raise ValueError("Parameter 'overwrite' is None.") 

614 elif not isinstance(overwrite, bool): 

615 ex = TypeError("Parameter 'overwrite' is not of type 'bool'.") 

616 ex.add_note(f"Got type '{getFullyQualifiedName(overwrite)}'.") 

617 raise ex 

618 elif not overwrite and self._xmlDocument is not None: 

619 ex = CodeCoverageError(f"XML document of Cobertura report file '{self._path}' is already populated.") 

620 ex.add_note("Call 'Document.Generate(overwrite=True)' to replace it.") 

621 raise ex 

622 

623 counts = Element._CountLines( 

624 line for package in self._packages for klass in package._classes for line in klass._lines.values() 

625 ) 

626 linesValid, linesCovered, branchesValid, branchesCovered = counts 

627 lineRate, branchRate = Element._Rates(counts) 

628 

629 rootElement = XMLElement("coverage") 

630 rootElement.attrib["line-rate"] = str(round(lineRate if self._lineRate is None else self._lineRate, 4)) 

631 rootElement.attrib["branch-rate"] = str(round(branchRate if self._branchRate is None else self._branchRate, 4)) 

632 for name, stated, computed in ( 

633 ("lines-covered", self._linesCovered, linesCovered), 

634 ("lines-valid", self._linesValid, linesValid), 

635 ("branches-covered", self._branchesCovered, branchesCovered), 

636 ("branches-valid", self._branchesValid, branchesValid) 

637 ): 

638 rootElement.attrib[name] = str(computed if stated is None else stated) 

639 rootElement.attrib["complexity"] = str(0 if self._complexity is None else self._complexity) 

640 rootElement.attrib["version"] = "" if self._version is None else self._version 

641 rootElement.attrib["timestamp"] = "" if self._timestamp is None else self._timestamp 

642 

643 sourcesElement = SubElement(rootElement, "sources") 

644 for source in self._sources: 

645 SubElement(sourcesElement, "source").text = source 

646 

647 packagesElement = SubElement(rootElement, "packages") 

648 for package in self._packages: 

649 packagesElement.append(package.Generate()) 

650 

651 document = ElementTree(rootElement) 

652 document.docinfo.system_url = "http://cobertura.sourceforge.net/xml/coverage-04.dtd" 

653 self._xmlDocument = document 

654 

655 def Write(self, path: Nullable[Path] = None, overwrite: bool = False, regenerate: bool = False) -> None: 

656 """ 

657 Write the XML document to a file. 

658 

659 :param path: Optional, path to the XML file, if not the report's path. Default: ``None``. 

660 :param overwrite: Optional, if true, overwrite an existing file. Default: ``False``. 

661 :param regenerate: Optional, if true, generate the XML document from the format's model first. 

662 Default: ``False``. 

663 :raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path`. 

664 :raises ValueError: If parameter ``overwrite`` is ``None``. 

665 :raises TypeError: If parameter ``overwrite`` isn't of type :class:`bool`. 

666 :raises ValueError: If parameter ``regenerate`` is ``None``. 

667 :raises TypeError: If parameter ``regenerate`` isn't of type :class:`bool`. 

668 :raises CodeCoverageError: If the file exists and parameter ``overwrite`` is false. 

669 :raises CodeCoverageError: If the XML document was neither read nor generated. |br| 

670 Call 'Document.Generate()' or 'Document.Write(..., regenerate=True)'. 

671 :raises CodeCoverageError: If the file can't be written. 

672 """ 

673 if path is None: 

674 path = self._path 

675 elif not isinstance(path, Path): 

676 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

677 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

678 raise ex 

679 

680 if overwrite is None: 

681 raise ValueError("Parameter 'overwrite' is None.") 

682 elif not isinstance(overwrite, bool): 

683 ex = TypeError("Parameter 'overwrite' is not of type 'bool'.") 

684 ex.add_note(f"Got type '{getFullyQualifiedName(overwrite)}'.") 

685 raise ex 

686 

687 if regenerate is None: 

688 raise ValueError("Parameter 'regenerate' is None.") 

689 elif not isinstance(regenerate, bool): 

690 ex = TypeError("Parameter 'regenerate' is not of type 'bool'.") 

691 ex.add_note(f"Got type '{getFullyQualifiedName(regenerate)}'.") 

692 raise ex 

693 

694 if not overwrite and path.exists(): 

695 raise CodeCoverageError(f"Cobertura report file '{path}' can not be overwritten.") \ 

696 from FileExistsError(f"File '{path}' already exists.") 

697 

698 if regenerate: 

699 self.Generate(overwrite=True) 

700 

701 if self._xmlDocument is None: 

702 ex = CodeCoverageError(f"XML document of Cobertura report file '{path}' needs to be generated first.") 

703 ex.add_note("Call 'Document.Generate()' or 'Document.Write(..., regenerate=True)'.") 

704 raise ex 

705 

706 content = tostring(self._xmlDocument, encoding="utf-8", xml_declaration=True, pretty_print=True) 

707 try: 

708 with path.open("wb") as file: 

709 file.write(content) 

710 except OSError as ex: 

711 raise CodeCoverageError(f"Cobertura report file '{path}' can not be written.") from ex