Coverage for pyEDAA/Reports/CodeCoverage/JaCoCo.py: 91%
603 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
1# ==================================================================================================================== #
2# _____ ____ _ _ ____ _ #
3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
7# |_| |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Electronic Design Automation Abstraction (EDA²) #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32JaCoCo's XML code coverage format: a model of the format, read from a report and converted to the common model.
34JaCoCo - the Java code coverage library - writes its XML report with ``jacococli.jar report --xml``, Maven's goals
35``report`` and ``report-aggregate``, Ant's task ``report`` or Gradle's task ``jacocoTestReport``. A report names its
36DTD by a public identifier, e.g. ``-//JACOCO//DTD Report 1.1//EN``, whose version (:class:`FormatVersion`) chooses the
37XML schema a report is validated against: :file:`JaCoCo-1.1.xsd`, a translation of JaCoCo's DTD ``report.dtd``. The
38format's model keeps what the report states: a :class:`Document` holds :class:`SessionInfo` elements, :class:`Group`
39elements - nested -, and :class:`Package` elements; a package holds :class:`Class` elements with their :class:`Method`
40elements, and :class:`SourceFile` elements with their :class:`Line` elements. Every element but a line and a session
41states its :class:`Counter` elements. Each element's constructor takes typed values, so the model can be built by hand:
42an element below the report names its parent with the keyword parameter ``parent`` and is added to it. Its class method
43``Parse`` reads the element's XML element.
45:meth:`Document.ToCoverageSummary` converts the model to the common model of :mod:`pyEDAA.Reports.CodeCoverage`:
47* A source file's path is its package's name in VM notation - e.g. ``my/pack`` - and its name, relative to a source
48 directory, which the report doesn't name.
49* A line ran, if it has a covered instruction - partially covered, if it has a missed branch. Its missed and covered
50 branches become as many branches. The format has no counts, no branch targets and no excluded lines.
51* The packages, classes and methods become units of the logical hierarchy; a package's name is split at ``/`` into
52 nested packages. A method spans its first line and the next lines of its file, as many as its ``LINE`` counter
53 counts; a class spans its methods. The groups have no counterpart.
55.. rubric:: Example
57.. code-block:: Python
59 from pathlib import Path
60 from pyEDAA.Reports.CodeCoverage.JaCoCo import Document
62 report = Document(Path("jacocoTestReport.xml"), analyzeAndConvert=True)
63 summary = report.ToCoverageSummary()
64 for unit in summary.IterateUnits():
65 print(f"{unit.QualifiedName}: {unit.LineCoverage:.1%}")
66"""
67from __future__ import annotations
69from datetime import datetime, timedelta, timezone
70from io import BytesIO
71from itertools import islice
72from pathlib import Path
73from re import compile as re_compile
74from typing import Generator, Generic, Optional as Nullable, Self, TypeVar
76from lxml.etree import XMLParser, XMLSchema, XMLSchemaParseError, XMLSyntaxError
77from lxml.etree import _Element, _ElementTree, parse
78from pyTooling.Common import getFullyQualifiedName, getResourceFile, StringEnum
79from pyTooling.Decorators import export, readonly
80from pyTooling.Exceptions import ToolingException
81from pyTooling.MetaClasses import ExtendedType, abstractclass
82from pyTooling.Stopwatch import Stopwatch
84from pyEDAA.Reports import Resources
85from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, Class as cc_Class, CodeCoverageError
86from pyEDAA.Reports.CodeCoverage import CoverageSummary, Document as cc_Document, Line as cc_Line
87from pyEDAA.Reports.CodeCoverage import LineCoverageStatus, Method as cc_Method, Package as cc_Package
88from pyEDAA.Reports.CodeCoverage import Unit as cc_Unit
91__all__ = ["PUBLIC_IDENTIFIER", "SCHEMAS"]
93#: Pattern of the DTD's public identifier a report states, capturing the version, e.g. ``1.1``.
94PUBLIC_IDENTIFIER = re_compile(r"-//JACOCO//DTD Report (\S+)//EN")
96ParentType = TypeVar("ParentType", bound="Report | Group")
97"""A type variable for the parent of a :class:`Group` or :class:`Package`: a :class:`Report` or a :class:`Group`."""
100@export
101class FormatVersion(StringEnum):
102 """
103 Version of the JaCoCo XML format: the version of the DTD, whose public identifier a report states.
104 """
106 Version1_1 = "1.1" #: Format 1.1, public identifier ``-//JACOCO//DTD Report 1.1//EN``.
109# A class with a property named like a class - ``FormatVersion`` - can't name that class in the annotation of a field:
110# the class body's namespace, where annotations are evaluated, binds the name to the property.
111_FormatVersion = FormatVersion
113SCHEMAS: dict[FormatVersion, str] = {
114 FormatVersion.Version1_1: "JaCoCo-1.1.xsd"
115} #: Per format version, the XML schema a report of that version is validated against.
118@export
119class CounterType(StringEnum):
120 """
121 Kind of items a ``<counter>`` counts, as its ``type`` states.
122 """
124 Instruction = "INSTRUCTION" #: Java bytecode instructions.
125 Branch = "BRANCH" #: Branches of ``if`` and ``switch`` statements.
126 Line = "LINE" #: Source lines with at least one instruction.
127 Complexity = "COMPLEXITY" #: Cyclomatic complexity: paths, which in linear combination generate all paths.
128 Method = "METHOD" #: Methods - also constructors and static initializers - with at least one instruction.
129 Class = "CLASS" #: Classes with at least one method.
132@export
133class CountersMixin(metaclass=ExtendedType, mixin=True):
134 """
135 A mixin-class adding the counters an element states: per kind of items, how many were missed and covered.
136 """
138 _counters: dict[CounterType, Counter] #: The counters, by kind.
140 def __init__(self) -> None:
141 """
142 Initialize the counters to none.
144 A counter is added by creating it with the element as its parent.
145 """
146 self._counters = {}
148 @readonly
149 def Counters(self) -> dict[CounterType, Counter]:
150 """
151 Read-only property to access the counters (:attr:`_counters`).
153 :returns: The counters, by kind; a kind without items has no counter.
154 """
155 return self._counters
158@export
159@abstractclass
160class Base(metaclass=ExtendedType, slots=True):
161 """
162 Base-class of the named elements below the report.
164 Packages and classes are named elements.
165 """
167 _name: str #: Name of the element.
169 def __init__(self, name: str) -> None:
170 """
171 Initialize the name of the element.
173 :param name: Name of the element.
174 :raises ValueError: If parameter ``name`` is ``None``.
175 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
176 """
177 if name is None:
178 raise ValueError("Parameter 'name' is None.")
179 elif not isinstance(name, str):
180 ex = TypeError("Parameter 'name' is not of type 'str'.")
181 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
182 raise ex
184 self._name = name
186 @readonly
187 def Name(self) -> str:
188 """
189 Read-only property to access the name of the element (:attr:`_name`).
191 :returns: The name.
192 """
193 return self._name
196@export
197class Counter(metaclass=ExtendedType, slots=True):
198 """
199 A ``<counter>`` of an element: how many items of a kind were missed and covered.
200 """
202 _parent: Nullable[CountersMixin] #: The element the counter belongs to.
203 _type: CounterType #: Kind of items the counter counts.
204 _missedCount: int #: Number of missed items.
205 _coveredCount: int #: Number of covered items.
207 def __init__(
208 self,
209 counterType: CounterType,
210 missedCount: int,
211 coveredCount: int,
212 *,
213 parent: Nullable[CountersMixin] = None
214 ) -> None:
215 """
216 Initialize the counter, and add it to the counters of its element.
218 :param counterType: Kind of items the counter counts.
219 :param missedCount: Number of missed items.
220 :param coveredCount: Number of covered items.
221 :param parent: Optional, the element the counter belongs to; the counter is added to its counters by
222 :attr:`Type`. Default: ``None``.
223 :raises ValueError: If parameter ``counterType`` is ``None``.
224 :raises TypeError: If parameter ``counterType`` isn't of type :class:`CounterType`.
225 :raises ValueError: If parameter ``missedCount`` is ``None``.
226 :raises TypeError: If parameter ``missedCount`` isn't of type :class:`int`.
227 :raises ValueError: If parameter ``missedCount`` is negative.
228 :raises ValueError: If parameter ``coveredCount`` is ``None``.
229 :raises TypeError: If parameter ``coveredCount`` isn't of type :class:`int`.
230 :raises ValueError: If parameter ``coveredCount`` is negative.
231 :raises TypeError: If parameter ``parent`` isn't of type :class:`CountersMixin`.
232 """
233 if counterType is None: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 raise ValueError("Parameter 'counterType' is None.")
235 elif not isinstance(counterType, CounterType):
236 ex = TypeError("Parameter 'counterType' is not of type 'CounterType'.")
237 ex.add_note(f"Got type '{getFullyQualifiedName(counterType)}'.")
238 raise ex
240 if missedCount is None:
241 raise ValueError("Parameter 'missedCount' is None.")
242 elif not isinstance(missedCount, int): 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 ex = TypeError("Parameter 'missedCount' is not of type 'int'.")
244 ex.add_note(f"Got type '{getFullyQualifiedName(missedCount)}'.")
245 raise ex
246 elif missedCount < 0: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true
247 ex = ValueError("Parameter 'missedCount' is negative.")
248 ex.add_note(f"Got value '{missedCount}'.")
249 raise ex
251 if coveredCount is None: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 raise ValueError("Parameter 'coveredCount' is None.")
253 elif not isinstance(coveredCount, int): 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true
254 ex = TypeError("Parameter 'coveredCount' is not of type 'int'.")
255 ex.add_note(f"Got type '{getFullyQualifiedName(coveredCount)}'.")
256 raise ex
257 elif coveredCount < 0:
258 ex = ValueError("Parameter 'coveredCount' is negative.")
259 ex.add_note(f"Got value '{coveredCount}'.")
260 raise ex
262 if parent is not None and not isinstance(parent, CountersMixin):
263 ex = TypeError("Parameter 'parent' is not of type 'CountersMixin'.")
264 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
265 raise ex
267 self._parent = parent
268 self._type = counterType
269 self._missedCount = missedCount
270 self._coveredCount = coveredCount
272 if parent is not None: 272 ↛ exitline 272 didn't return from function '__init__' because the condition on line 272 was always true
273 parent._counters[self._type] = self
275 @classmethod
276 def Parse(cls, element: _Element, *, parent: Nullable[CountersMixin] = None) -> Self:
277 """
278 Parse a counter from its ``<counter>`` element.
280 :param element: The ``<counter>`` element.
281 :param parent: Optional, the element the counter belongs to. Default: ``None``.
282 :returns: The counter.
283 """
284 return cls(
285 CounterType(element.attrib["type"]),
286 int(element.attrib["missed"]),
287 int(element.attrib["covered"]),
288 parent=parent
289 )
291 @readonly
292 def Parent(self) -> Nullable[CountersMixin]:
293 """
294 Read-only property to access the element the counter belongs to (:attr:`_parent`).
296 :returns: The element; ``None`` if the counter belongs to no element.
297 """
298 return self._parent
300 @readonly
301 def Type(self) -> CounterType:
302 """
303 Read-only property to access the kind of items the counter counts (:attr:`_type`).
305 :returns: The kind of items.
306 """
307 return self._type
309 @readonly
310 def MissedCount(self) -> int:
311 """
312 Read-only property to access the number of missed items (:attr:`_missedCount`).
314 :returns: The number of missed items.
315 """
316 return self._missedCount
318 @readonly
319 def CoveredCount(self) -> int:
320 """
321 Read-only property to access the number of covered items (:attr:`_coveredCount`).
323 :returns: The number of covered items.
324 """
325 return self._coveredCount
328@export
329class SessionInfo(metaclass=ExtendedType, slots=True):
330 """
331 A ``<sessioninfo>`` of the report: a session, which contributed execution data - its ID, its start and its dump.
332 """
334 _parent: Nullable[Report] #: The report the session belongs to.
335 _sessionID: str #: ID of the session.
336 _start: datetime #: Time the session started.
337 _dump: datetime #: Time the session's execution data was dumped.
339 def __init__(self, sessionID: str, start: datetime, dump: datetime, *, parent: Nullable[Report] = None) -> None:
340 """
341 Initialize the session, and append it to the sessions of its report.
343 :param sessionID: ID of the session, e.g. the host's name and a random number.
344 :param start: Time the session started.
345 :param dump: Time the session's execution data was dumped.
346 :param parent: Optional, the report the session belongs to; the session is appended to its sessions.
347 Default: ``None``.
348 :raises ValueError: If parameter ``sessionID`` is ``None``.
349 :raises TypeError: If parameter ``sessionID`` isn't of type :class:`str`.
350 :raises ValueError: If parameter ``start`` is ``None``.
351 :raises TypeError: If parameter ``start`` isn't of type :class:`~datetime.datetime`.
352 :raises ValueError: If parameter ``dump`` is ``None``.
353 :raises TypeError: If parameter ``dump`` isn't of type :class:`~datetime.datetime`.
354 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report`.
355 """
356 if sessionID is None: 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 raise ValueError("Parameter 'sessionID' is None.")
358 elif not isinstance(sessionID, str): 358 ↛ 359line 358 didn't jump to line 359 because the condition on line 358 was never true
359 ex = TypeError("Parameter 'sessionID' is not of type 'str'.")
360 ex.add_note(f"Got type '{getFullyQualifiedName(sessionID)}'.")
361 raise ex
363 if start is None: 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true
364 raise ValueError("Parameter 'start' is None.")
365 elif not isinstance(start, datetime):
366 ex = TypeError("Parameter 'start' is not of type 'datetime'.")
367 ex.add_note(f"Got type '{getFullyQualifiedName(start)}'.")
368 raise ex
370 if dump is None: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true
371 raise ValueError("Parameter 'dump' is None.")
372 elif not isinstance(dump, datetime): 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true
373 ex = TypeError("Parameter 'dump' is not of type 'datetime'.")
374 ex.add_note(f"Got type '{getFullyQualifiedName(dump)}'.")
375 raise ex
377 if parent is not None and not isinstance(parent, Report):
378 ex = TypeError("Parameter 'parent' is not of type 'Report'.")
379 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
380 raise ex
382 self._parent = parent
383 self._sessionID = sessionID
384 self._start = start
385 self._dump = dump
387 if parent is not None:
388 parent._sessionInfos.append(self)
390 @classmethod
391 def Parse(cls, element: _Element, *, parent: Nullable[Report] = None) -> Self:
392 """
393 Parse a session from its ``<sessioninfo>`` element.
395 The times are stated in milliseconds since the epoch.
397 :param element: The ``<sessioninfo>`` element.
398 :param parent: Optional, the report the session belongs to. Default: ``None``.
399 :returns: The session.
400 """
401 epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
402 return cls(
403 element.attrib["id"],
404 epoch + timedelta(milliseconds=int(element.attrib["start"])),
405 epoch + timedelta(milliseconds=int(element.attrib["dump"])),
406 parent=parent
407 )
409 @readonly
410 def Parent(self) -> Nullable[Report]:
411 """
412 Read-only property to access the report the session belongs to (:attr:`_parent`).
414 :returns: The report; ``None`` if the session belongs to no report.
415 """
416 return self._parent
418 @readonly
419 def SessionID(self) -> str:
420 """
421 Read-only property to access the ID of the session (:attr:`_sessionID`).
423 JaCoCo's agent names a session by the host's name and a random number, e.g. ``dffa20ef8728-f9da5560``.
425 :returns: The ID.
426 """
427 return self._sessionID
429 @readonly
430 def Start(self) -> datetime:
431 """
432 Read-only property to access the time the session started (:attr:`_start`).
434 :returns: The time, with time zone UTC, if read from a report.
435 """
436 return self._start
438 @readonly
439 def Dump(self) -> datetime:
440 """
441 Read-only property to access the time the session's execution data was dumped (:attr:`_dump`).
443 :returns: The time, with time zone UTC, if read from a report.
444 """
445 return self._dump
448@export
449class Method(Base, CountersMixin):
450 """
451 A ``<method>`` of a class: its descriptor, its first line and its counters.
452 """
454 _parent: Nullable[Class] #: The class the method belongs to.
455 _descriptor: str #: Descriptor of the method: the types of its parameters and result, in VM notation.
456 _lineNumber: Nullable[int] #: The method's first source line, if the report says.
458 def __init__(
459 self,
460 name: str,
461 descriptor: str,
462 lineNumber: Nullable[int] = None,
463 *,
464 parent: Nullable[Class] = None
465 ) -> None:
466 """
467 Initialize the method, and add it to the methods of its class.
469 Its counters are added by creating them with this method as their parent.
471 :param name: Name of the method, e.g. ``absolute``; ``<init>`` for a constructor.
472 :param descriptor: Descriptor of the method, e.g. ``(I)I`` for a method taking and returning an ``int``.
473 :param lineNumber: Optional, the method's first source line. Default: ``None``.
474 :param parent: Optional, the class the method belongs to; the method is added to its methods by
475 :attr:`Name` and :attr:`Descriptor`. Default: ``None``.
476 :raises ValueError: If parameter ``name`` is ``None``.
477 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
478 :raises ValueError: If parameter ``name`` is empty.
479 :raises ValueError: If parameter ``descriptor`` is ``None``.
480 :raises TypeError: If parameter ``descriptor`` isn't of type :class:`str`.
481 :raises ValueError: If parameter ``descriptor`` is empty.
482 :raises TypeError: If parameter ``lineNumber`` isn't of type :class:`int`.
483 :raises ValueError: If parameter ``lineNumber`` is less than 1.
484 :raises TypeError: If parameter ``parent`` isn't of type :class:`Class`.
485 """
486 super().__init__(name)
487 CountersMixin.__init__(self)
489 if name == "": 489 ↛ 490line 489 didn't jump to line 490 because the condition on line 489 was never true
490 raise ValueError("Parameter 'name' is empty.")
492 if descriptor is None:
493 raise ValueError("Parameter 'descriptor' is None.")
494 elif not isinstance(descriptor, str): 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true
495 ex = TypeError("Parameter 'descriptor' is not of type 'str'.")
496 ex.add_note(f"Got type '{getFullyQualifiedName(descriptor)}'.")
497 raise ex
498 elif descriptor == "":
499 raise ValueError("Parameter 'descriptor' is empty.")
501 if lineNumber is not None:
502 if not isinstance(lineNumber, int): 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true
503 ex = TypeError("Parameter 'lineNumber' is not of type 'int'.")
504 ex.add_note(f"Got type '{getFullyQualifiedName(lineNumber)}'.")
505 raise ex
506 elif lineNumber < 1:
507 ex = ValueError("Parameter 'lineNumber' is less than 1.")
508 ex.add_note(f"Got value '{lineNumber}'.")
509 raise ex
511 if parent is not None and not isinstance(parent, Class):
512 ex = TypeError("Parameter 'parent' is not of type 'Class'.")
513 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
514 raise ex
516 self._parent = parent
517 self._descriptor = descriptor
518 self._lineNumber = lineNumber
520 if parent is not None:
521 parent._methods[f"{self._name}{self._descriptor}"] = self
523 @classmethod
524 def Parse(cls, element: _Element, *, parent: Nullable[Class] = None) -> Self:
525 """
526 Parse a method and its counters from its ``<method>`` element.
528 :param element: The ``<method>`` element.
529 :param parent: Optional, the class the method belongs to. Default: ``None``.
530 :returns: The method.
531 :raises CodeCoverageError: If the method states a counter twice.
532 """
533 method = cls(
534 element.attrib["name"],
535 element.attrib["desc"],
536 int(line) if (line := element.attrib.get("line")) is not None else None,
537 parent=parent
538 )
540 for counterElement in element.iterfind("counter"):
541 if (counterType := CounterType(counterElement.attrib["type"])) in method._counters:
542 raise CodeCoverageError(
543 f"JaCoCo method '{method._name}{method._descriptor}' states counter '{counterType}' twice."
544 )
546 Counter.Parse(counterElement, parent=method)
548 return method
550 @readonly
551 def Parent(self) -> Nullable[Class]:
552 """
553 Read-only property to access the class the method belongs to (:attr:`_parent`).
555 :returns: The class; ``None`` if the method belongs to no class.
556 """
557 return self._parent
559 @readonly
560 def Descriptor(self) -> str:
561 """
562 Read-only property to access the descriptor of the method (:attr:`_descriptor`).
564 The descriptor ``([Ljava/lang/String;)V`` is of a method taking a ``String[]`` and returning ``void``.
566 :returns: The descriptor, in VM notation.
567 """
568 return self._descriptor
570 @readonly
571 def LineNumber(self) -> Nullable[int]:
572 """
573 Read-only property to access the method's first source line (:attr:`_lineNumber`).
575 A method of a class compiled without debug information has none.
577 :returns: The line number, or ``None`` if the report doesn't say.
578 """
579 return self._lineNumber
582@export
583class Class(Base, CountersMixin):
584 """
585 A ``<class>`` of a package: its source file, its methods and its counters.
586 """
588 _parent: Nullable[Package] #: The package the class belongs to.
589 _sourceFileName: Nullable[str] #: Name of the class' source file, if the report says.
590 _methods: dict[str, Method] #: The methods, by name and descriptor.
592 def __init__(self, name: str, sourceFileName: Nullable[str] = None, *, parent: Nullable[Package] = None) -> None:
593 """
594 Initialize the class, and add it to the classes of its package.
596 Its methods and counters are added by creating them with this class as their parent.
598 :param name: Fully qualified name of the class in VM notation, e.g. ``my/pack/MyClass``.
599 :param sourceFileName: Optional, name of the class' source file, e.g. ``MyClass.java``. Default: ``None``.
600 :param parent: Optional, the package the class belongs to; the class is added to its classes by
601 :attr:`Name`. Default: ``None``.
602 :raises ValueError: If parameter ``name`` is ``None``.
603 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
604 :raises ValueError: If parameter ``name`` is empty.
605 :raises TypeError: If parameter ``sourceFileName`` isn't of type :class:`str`.
606 :raises TypeError: If parameter ``parent`` isn't of type :class:`Package`.
607 """
608 super().__init__(name)
609 CountersMixin.__init__(self)
611 if name == "":
612 raise ValueError("Parameter 'name' is empty.")
614 if sourceFileName is not None and not isinstance(sourceFileName, str):
615 ex = TypeError("Parameter 'sourceFileName' is not of type 'str'.")
616 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFileName)}'.")
617 raise ex
619 if parent is not None and not isinstance(parent, Package):
620 ex = TypeError("Parameter 'parent' is not of type 'Package'.")
621 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
622 raise ex
624 self._parent = parent
625 self._sourceFileName = sourceFileName
626 self._methods = {}
628 if parent is not None:
629 parent._classes[self._name] = self
631 @classmethod
632 def Parse(cls, element: _Element, *, parent: Nullable[Package] = None) -> Self:
633 """
634 Parse a class, its methods and counters from its ``<class>`` element.
636 :param element: The ``<class>`` element.
637 :param parent: Optional, the package the class belongs to. Default: ``None``.
638 :returns: The class.
639 :raises CodeCoverageError: If the class states a method twice.
640 :raises CodeCoverageError: If the class states a counter twice.
641 """
642 klass = cls(element.attrib["name"], element.attrib.get("sourcefilename"), parent=parent)
644 for methodElement in element.iterfind("method"):
645 if (key := f"{methodElement.attrib['name']}{methodElement.attrib['desc']}") in klass._methods:
646 raise CodeCoverageError(f"JaCoCo class '{klass._name}' states method '{key}' twice.")
648 Method.Parse(methodElement, parent=klass)
650 for counterElement in element.iterfind("counter"):
651 if (counterType := CounterType(counterElement.attrib["type"])) in klass._counters: 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true
652 raise CodeCoverageError(f"JaCoCo class '{klass._name}' states counter '{counterType}' twice.")
654 Counter.Parse(counterElement, parent=klass)
656 return klass
658 @readonly
659 def Parent(self) -> Nullable[Package]:
660 """
661 Read-only property to access the package the class belongs to (:attr:`_parent`).
663 :returns: The package; ``None`` if the class belongs to no package.
664 """
665 return self._parent
667 @readonly
668 def SourceFileName(self) -> Nullable[str]:
669 """
670 Read-only property to access the name of the class' source file (:attr:`_sourceFileName`).
672 A class compiled without debug information names no source file.
674 :returns: The name of a :class:`SourceFile` of the class' package, or ``None`` if the report doesn't say.
675 """
676 return self._sourceFileName
678 @readonly
679 def Methods(self) -> dict[str, Method]:
680 """
681 Read-only property to access the methods (:attr:`_methods`).
683 A method is keyed by its name and descriptor, e.g. ``absolute(I)I``.
685 :returns: The methods, by name and descriptor.
686 """
687 return self._methods
690@export
691class Line(metaclass=ExtendedType, slots=True):
692 """
693 A ``<line>`` of a source file: its missed and covered instructions and branches.
694 """
696 _parent: Nullable[SourceFile] #: The source file the line belongs to.
697 _lineNumber: int #: Line number, counted from 1.
698 _missedInstructionCount: Nullable[int] #: Number of missed instructions, if the report says.
699 _coveredInstructionCount: Nullable[int] #: Number of covered instructions, if the report says.
700 _missedBranchCount: Nullable[int] #: Number of missed branches, if the report says.
701 _coveredBranchCount: Nullable[int] #: Number of covered branches, if the report says.
703 def __init__(
704 self,
705 lineNumber: int,
706 missedInstructionCount: Nullable[int] = None,
707 coveredInstructionCount: Nullable[int] = None,
708 missedBranchCount: Nullable[int] = None,
709 coveredBranchCount: Nullable[int] = None,
710 *,
711 parent: Nullable[SourceFile] = None
712 ) -> None:
713 """
714 Initialize the line, and add it to the lines of its source file.
716 :param lineNumber: Line number, counted from 1.
717 :param missedInstructionCount: Optional, number of missed instructions. Default: ``None``.
718 :param coveredInstructionCount: Optional, number of covered instructions. Default: ``None``.
719 :param missedBranchCount: Optional, number of missed branches. Default: ``None``.
720 :param coveredBranchCount: Optional, number of covered branches. Default: ``None``.
721 :param parent: Optional, the source file the line belongs to; the line is added to its lines by
722 :attr:`LineNumber`. Default: ``None``.
723 :raises ValueError: If parameter ``lineNumber`` is ``None``.
724 :raises TypeError: If parameter ``lineNumber`` isn't of type :class:`int`.
725 :raises ValueError: If parameter ``lineNumber`` is less than 1.
726 :raises TypeError: If parameter ``missedInstructionCount`` isn't of type :class:`int`.
727 :raises ValueError: If parameter ``missedInstructionCount`` is negative.
728 :raises TypeError: If parameter ``coveredInstructionCount`` isn't of type :class:`int`.
729 :raises ValueError: If parameter ``coveredInstructionCount`` is negative.
730 :raises TypeError: If parameter ``missedBranchCount`` isn't of type :class:`int`.
731 :raises ValueError: If parameter ``missedBranchCount`` is negative.
732 :raises TypeError: If parameter ``coveredBranchCount`` isn't of type :class:`int`.
733 :raises ValueError: If parameter ``coveredBranchCount`` is negative.
734 :raises TypeError: If parameter ``parent`` isn't of type :class:`SourceFile`.
735 """
736 if lineNumber is None:
737 raise ValueError("Parameter 'lineNumber' is None.")
738 elif not isinstance(lineNumber, int):
739 ex = TypeError("Parameter 'lineNumber' is not of type 'int'.")
740 ex.add_note(f"Got type '{getFullyQualifiedName(lineNumber)}'.")
741 raise ex
742 elif lineNumber < 1:
743 ex = ValueError("Parameter 'lineNumber' is less than 1.")
744 ex.add_note(f"Got value '{lineNumber}'.")
745 raise ex
747 for name, count in (
748 ("missedInstructionCount", missedInstructionCount),
749 ("coveredInstructionCount", coveredInstructionCount),
750 ("missedBranchCount", missedBranchCount),
751 ("coveredBranchCount", coveredBranchCount)
752 ):
753 if count is not None:
754 if not isinstance(count, int):
755 ex = TypeError(f"Parameter '{name}' is not of type 'int'.")
756 ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.")
757 raise ex
758 elif count < 0:
759 ex = ValueError(f"Parameter '{name}' is negative.")
760 ex.add_note(f"Got value '{count}'.")
761 raise ex
763 if parent is not None and not isinstance(parent, SourceFile):
764 ex = TypeError("Parameter 'parent' is not of type 'SourceFile'.")
765 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
766 raise ex
768 self._parent = parent
769 self._lineNumber = lineNumber
770 self._missedInstructionCount = missedInstructionCount
771 self._coveredInstructionCount = coveredInstructionCount
772 self._missedBranchCount = missedBranchCount
773 self._coveredBranchCount = coveredBranchCount
775 if parent is not None:
776 parent._lines[self._lineNumber] = self
778 @classmethod
779 def Parse(cls, element: _Element, *, parent: Nullable[SourceFile] = None) -> Self:
780 """
781 Parse a line from its ``<line>`` element.
783 :param element: The ``<line>`` element.
784 :param parent: Optional, the source file the line belongs to. Default: ``None``.
785 :returns: The line.
786 """
787 attributes = element.attrib
788 return cls(
789 int(attributes["nr"]),
790 int(attributes["mi"]) if "mi" in attributes else None,
791 int(attributes["ci"]) if "ci" in attributes else None,
792 int(attributes["mb"]) if "mb" in attributes else None,
793 int(attributes["cb"]) if "cb" in attributes else None,
794 parent=parent
795 )
797 @readonly
798 def Parent(self) -> Nullable[SourceFile]:
799 """
800 Read-only property to access the source file the line belongs to (:attr:`_parent`).
802 :returns: The source file; ``None`` if the line belongs to no source file.
803 """
804 return self._parent
806 @readonly
807 def LineNumber(self) -> int:
808 """
809 Read-only property to access the line number (:attr:`_lineNumber`).
811 :returns: The line number, counted from 1.
812 """
813 return self._lineNumber
815 @readonly
816 def MissedInstructionCount(self) -> Nullable[int]:
817 """
818 Read-only property to access the number of missed instructions (:attr:`_missedInstructionCount`).
820 :returns: The number, or ``None`` if the report doesn't say.
821 """
822 return self._missedInstructionCount
824 @readonly
825 def CoveredInstructionCount(self) -> Nullable[int]:
826 """
827 Read-only property to access the number of covered instructions (:attr:`_coveredInstructionCount`).
829 :returns: The number, or ``None`` if the report doesn't say.
830 """
831 return self._coveredInstructionCount
833 @readonly
834 def MissedBranchCount(self) -> Nullable[int]:
835 """
836 Read-only property to access the number of missed branches (:attr:`_missedBranchCount`).
838 :returns: The number, or ``None`` if the report doesn't say.
839 """
840 return self._missedBranchCount
842 @readonly
843 def CoveredBranchCount(self) -> Nullable[int]:
844 """
845 Read-only property to access the number of covered branches (:attr:`_coveredBranchCount`).
847 :returns: The number, or ``None`` if the report doesn't say.
848 """
849 return self._coveredBranchCount
852@export
853class SourceFile(Base, CountersMixin):
854 """
855 A ``<sourcefile>`` of a package: its lines and its counters.
856 """
858 _parent: Nullable[Package] #: The package the source file belongs to.
859 _lines: dict[int, Line] #: The lines with instructions, by number.
861 def __init__(self, name: str, *, parent: Nullable[Package] = None) -> None:
862 """
863 Initialize the source file, and add it to the source files of its package.
865 Its lines and counters are added by creating them with this source file as their parent.
867 :param name: Name of the source file, without the package's directory, e.g. ``MyClass.java``.
868 :param parent: Optional, the package the source file belongs to; the source file is added to its source
869 files by :attr:`Name`. Default: ``None``.
870 :raises ValueError: If parameter ``name`` is ``None``.
871 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
872 :raises ValueError: If parameter ``name`` is empty.
873 :raises TypeError: If parameter ``parent`` isn't of type :class:`Package`.
874 """
875 super().__init__(name)
876 CountersMixin.__init__(self)
878 if name == "":
879 raise ValueError("Parameter 'name' is empty.")
881 if parent is not None and not isinstance(parent, Package):
882 ex = TypeError("Parameter 'parent' is not of type 'Package'.")
883 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
884 raise ex
886 self._parent = parent
887 self._lines = {}
889 if parent is not None:
890 parent._sourceFiles[self._name] = self
892 @classmethod
893 def Parse(cls, element: _Element, *, parent: Nullable[Package] = None) -> Self:
894 """
895 Parse a source file, its lines and counters from its ``<sourcefile>`` element.
897 :param element: The ``<sourcefile>`` element.
898 :param parent: Optional, the package the source file belongs to. Default: ``None``.
899 :returns: The source file.
900 :raises CodeCoverageError: If the source file states a line twice.
901 :raises CodeCoverageError: If the source file states a counter twice.
902 """
903 sourceFile = cls(element.attrib["name"], parent=parent)
905 for lineElement in element.iterfind("line"):
906 if (lineNumber := int(lineElement.attrib["nr"])) in sourceFile._lines:
907 raise CodeCoverageError(f"JaCoCo source file '{sourceFile._name}' states line {lineNumber} twice.")
909 Line.Parse(lineElement, parent=sourceFile)
911 for counterElement in element.iterfind("counter"):
912 if (counterType := CounterType(counterElement.attrib["type"])) in sourceFile._counters: 912 ↛ 913line 912 didn't jump to line 913 because the condition on line 912 was never true
913 raise CodeCoverageError(f"JaCoCo source file '{sourceFile._name}' states counter '{counterType}' twice.")
915 Counter.Parse(counterElement, parent=sourceFile)
917 return sourceFile
919 @readonly
920 def Parent(self) -> Nullable[Package]:
921 """
922 Read-only property to access the package the source file belongs to (:attr:`_parent`).
924 :returns: The package; ``None`` if the source file belongs to no package.
925 """
926 return self._parent
928 @readonly
929 def Lines(self) -> dict[int, Line]:
930 """
931 Read-only property to access the lines with instructions (:attr:`_lines`).
933 :returns: The lines, by number.
934 """
935 return self._lines
938@export
939class Package(Base, CountersMixin, Generic[ParentType]):
940 """
941 A ``<package>`` of the report or of a group: its classes and source files, and its counters.
942 """
944 _parent: Nullable[ParentType] #: The report or group the package belongs to.
945 _classes: dict[str, Class] #: The classes, by name.
946 _sourceFiles: dict[str, SourceFile] #: The source files, by name.
948 def __init__(self, name: str, *, parent: Nullable[ParentType] = None) -> None:
949 """
950 Initialize the package, and add it to the packages of its report or group.
952 Its classes, source files and counters are added by creating them with this package as their parent.
954 :param name: Name of the package in VM notation, e.g. ``my/pack``; empty for the default package.
955 :param parent: Optional, the report or group the package belongs to; the package is added to its packages
956 by :attr:`Name`. Default: ``None``.
957 :raises ValueError: If parameter ``name`` is ``None``.
958 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
959 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report` or :class:`Group`.
960 """
961 super().__init__(name)
962 CountersMixin.__init__(self)
964 if parent is not None and not isinstance(parent, (Report, Group)):
965 ex = TypeError("Parameter 'parent' is not of type 'Report' or 'Group'.")
966 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
967 raise ex
969 self._parent = parent
970 self._classes = {}
971 self._sourceFiles = {}
973 if parent is not None:
974 parent._packages[self._name] = self
976 @classmethod
977 def Parse(cls, element: _Element, *, parent: Nullable[ParentType] = None) -> Self:
978 """
979 Parse a package, its classes, source files and counters from its ``<package>`` element.
981 :param element: The ``<package>`` element.
982 :param parent: Optional, the report or group the package belongs to. Default: ``None``.
983 :returns: The package.
984 :raises CodeCoverageError: If the package states a class or source file twice.
985 :raises CodeCoverageError: If the package states a counter twice.
986 """
987 package = cls(element.attrib["name"], parent=parent)
989 for classElement in element.iterfind("class"):
990 if (name := classElement.attrib["name"]) in package._classes:
991 raise CodeCoverageError(f"JaCoCo package '{package._name}' states class '{name}' twice.")
993 Class.Parse(classElement, parent=package)
995 for sourceFileElement in element.iterfind("sourcefile"):
996 if (name := sourceFileElement.attrib["name"]) in package._sourceFiles: 996 ↛ 997line 996 didn't jump to line 997 because the condition on line 996 was never true
997 raise CodeCoverageError(f"JaCoCo package '{package._name}' states source file '{name}' twice.")
999 SourceFile.Parse(sourceFileElement, parent=package)
1001 for counterElement in element.iterfind("counter"):
1002 if (counterType := CounterType(counterElement.attrib["type"])) in package._counters: 1002 ↛ 1003line 1002 didn't jump to line 1003 because the condition on line 1002 was never true
1003 raise CodeCoverageError(f"JaCoCo package '{package._name}' states counter '{counterType}' twice.")
1005 Counter.Parse(counterElement, parent=package)
1007 return package
1009 @readonly
1010 def Parent(self) -> Nullable[ParentType]:
1011 """
1012 Read-only property to access the report or group the package belongs to (:attr:`_parent`).
1014 :returns: The :class:`Report` or :class:`Group`; ``None`` if the package belongs to none.
1015 """
1016 return self._parent
1018 @readonly
1019 def Classes(self) -> dict[str, Class]:
1020 """
1021 Read-only property to access the classes (:attr:`_classes`).
1023 A class is keyed by its name, e.g. ``my/pack/MyClass``, a nested class by its binary name, e.g.
1024 ``my/pack/MyClass$Inner``.
1026 :returns: The classes, by name.
1027 """
1028 return self._classes
1030 @readonly
1031 def SourceFiles(self) -> dict[str, SourceFile]:
1032 """
1033 Read-only property to access the source files (:attr:`_sourceFiles`).
1035 A source file is keyed by its name, e.g. ``MyClass.java``.
1037 :returns: The source files, by name.
1038 """
1039 return self._sourceFiles
1042@export
1043class Group(Base, CountersMixin, Generic[ParentType]):
1044 """
1045 A ``<group>`` of the report or of a group: its groups or packages, and its counters.
1047 JaCoCo groups packages, e.g. by module, if Maven's goal ``report-aggregate`` or Ant's task ``report`` asks for it.
1048 """
1050 _parent: Nullable[ParentType] #: The report or group the group belongs to.
1051 _groups: dict[str, Group[Group]] #: The groups, by name.
1052 _packages: dict[str, Package[Group]] #: The packages, by name.
1054 def __init__(self, name: str, *, parent: Nullable[ParentType] = None) -> None:
1055 """
1056 Initialize the group, and add it to the groups of its report or group.
1058 Its groups, packages and counters are added by creating them with this group as their parent.
1060 :param name: Name of the group, e.g. of a module.
1061 :param parent: Optional, the report or group the group belongs to; the group is added to its groups by
1062 :attr:`Name`. Default: ``None``.
1063 :raises ValueError: If parameter ``name`` is ``None``.
1064 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
1065 :raises ValueError: If parameter ``name`` is empty.
1066 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report` or :class:`Group`.
1067 """
1068 super().__init__(name)
1069 CountersMixin.__init__(self)
1071 if name == "":
1072 raise ValueError("Parameter 'name' is empty.")
1074 if parent is not None and not isinstance(parent, (Report, Group)):
1075 ex = TypeError("Parameter 'parent' is not of type 'Report' or 'Group'.")
1076 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
1077 raise ex
1079 self._parent = parent
1080 self._groups = {}
1081 self._packages = {}
1083 if parent is not None:
1084 parent._groups[self._name] = self
1086 @classmethod
1087 def Parse(cls, element: _Element, *, parent: Nullable[ParentType] = None) -> Self:
1088 """
1089 Parse a group, its groups, packages and counters from its ``<group>`` element.
1091 :param element: The ``<group>`` element.
1092 :param parent: Optional, the report or group the group belongs to. Default: ``None``.
1093 :returns: The group.
1094 :raises CodeCoverageError: If the group states a group or package twice.
1095 :raises CodeCoverageError: If the group states a counter twice.
1096 """
1097 group = cls(element.attrib["name"], parent=parent)
1099 for groupElement in element.iterfind("group"):
1100 if (name := groupElement.attrib["name"]) in group._groups: 1100 ↛ 1101line 1100 didn't jump to line 1101 because the condition on line 1100 was never true
1101 raise CodeCoverageError(f"JaCoCo group '{group._name}' states group '{name}' twice.")
1103 Group.Parse(groupElement, parent=group)
1105 for packageElement in element.iterfind("package"):
1106 if (name := packageElement.attrib["name"]) in group._packages:
1107 raise CodeCoverageError(f"JaCoCo group '{group._name}' states package '{name}' twice.")
1109 Package.Parse(packageElement, parent=group)
1111 for counterElement in element.iterfind("counter"):
1112 if (counterType := CounterType(counterElement.attrib["type"])) in group._counters: 1112 ↛ 1113line 1112 didn't jump to line 1113 because the condition on line 1112 was never true
1113 raise CodeCoverageError(f"JaCoCo group '{group._name}' states counter '{counterType}' twice.")
1115 Counter.Parse(counterElement, parent=group)
1117 return group
1119 @readonly
1120 def Parent(self) -> Nullable[ParentType]:
1121 """
1122 Read-only property to access the report or group the group belongs to (:attr:`_parent`).
1124 :returns: The :class:`Report` or :class:`Group`; ``None`` if the group belongs to none.
1125 """
1126 return self._parent
1128 @readonly
1129 def Groups(self) -> dict[str, Group[Group]]:
1130 """
1131 Read-only property to access the groups (:attr:`_groups`).
1133 :returns: The groups, by name.
1134 """
1135 return self._groups
1137 @readonly
1138 def Packages(self) -> dict[str, Package[Group]]:
1139 """
1140 Read-only property to access the packages (:attr:`_packages`).
1142 :returns: The packages, by name.
1143 """
1144 return self._packages
1147@export
1148class Report(CountersMixin, mixin=True):
1149 """
1150 The root element ``<report>``: its name, the sessions, the groups or packages, and the counters.
1152 The root of the format's model: a session, a group, a package or a counter names it as its parent.
1153 """
1155 _name: Nullable[str] #: Name of the report, e.g. of the project measured.
1156 _formatVersion: Nullable[_FormatVersion] #: Version of the report format.
1157 _sessionInfos: list[SessionInfo] #: The sessions, which contributed execution data.
1158 _groups: dict[str, Group[Report]] #: The groups, by name.
1159 _packages: dict[str, Package[Report]] #: The packages, by name.
1161 def __init__(self) -> None:
1162 """
1163 Initialize an empty report.
1164 """
1165 CountersMixin.__init__(self)
1167 self._name = None
1168 self._formatVersion = None
1169 self._sessionInfos = []
1170 self._groups = {}
1171 self._packages = {}
1173 @readonly
1174 def Name(self) -> Nullable[str]:
1175 """
1176 Read-only property to access the name of the report (:attr:`_name`).
1178 The name is chosen by the tool writing the report, e.g. the name of the project measured.
1180 :returns: The name; ``None`` before the report was converted.
1181 """
1182 return self._name
1184 @readonly
1185 def FormatVersion(self) -> Nullable[FormatVersion]:
1186 """
1187 Read-only property to access the version of the report format (:attr:`_formatVersion`).
1189 :returns: The format version; ``None`` before the report was analyzed.
1190 """
1191 return self._formatVersion
1193 @readonly
1194 def SessionInfos(self) -> list[SessionInfo]:
1195 """
1196 Read-only property to access the sessions, which contributed execution data (:attr:`_sessionInfos`).
1198 :returns: The sessions, in the order the report lists them.
1199 """
1200 return self._sessionInfos
1202 @readonly
1203 def Groups(self) -> dict[str, Group[Report]]:
1204 """
1205 Read-only property to access the groups (:attr:`_groups`).
1207 :returns: The groups, by name; empty, if the report has packages.
1208 """
1209 return self._groups
1211 @readonly
1212 def Packages(self) -> dict[str, Package[Report]]:
1213 """
1214 Read-only property to access the packages (:attr:`_packages`).
1216 :returns: The packages, by name; empty, if the report has groups.
1217 """
1218 return self._packages
1221@export
1222class Document(cc_Document, Report):
1223 """
1224 A JaCoCo XML code coverage report: read into the format's model, and converted to the common model.
1225 """
1227 _xmlDocument: Nullable[_ElementTree] #: The parsed and validated XML document, after :meth:`Analyze`.
1229 def __init__(self, xmlReportFile: Path, analyzeAndConvert: bool = False) -> None:
1230 """
1231 Initialize the report, and optionally read it.
1233 :param xmlReportFile: Path to the JaCoCo XML file.
1234 :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
1235 """
1236 super().__init__(xmlReportFile)
1237 Report.__init__(self)
1239 self._xmlDocument = None
1241 if analyzeAndConvert:
1242 self.Analyze()
1243 self.Convert()
1245 def Analyze(self) -> None:
1246 """
1247 Parse the XML file, read the format version from the DTD's public identifier, and validate the file against the
1248 XML schema of that version (:data:`SCHEMAS`).
1250 :raises CodeCoverageError: If the file doesn't exist.
1251 :raises CodeCoverageError: If the file can't be read.
1252 :raises CodeCoverageError: If the file isn't well-formed XML.
1253 :raises CodeCoverageError: If the root element isn't ``<report>``.
1254 :raises CodeCoverageError: If the file states no public identifier of a supported format version.
1255 :raises CodeCoverageError: If the XML schema can't be located or parsed.
1256 :raises CodeCoverageError: If the file isn't valid according to the XML schema.
1257 """
1258 if not self._path.exists():
1259 raise CodeCoverageError(f"JaCoCo report file '{self._path}' does not exist.") \
1260 from FileNotFoundError(f"File '{self._path}' not found.")
1262 with Stopwatch() as sw:
1263 try:
1264 content = self._path.read_bytes()
1265 except OSError as ex:
1266 raise CodeCoverageError(f"Couldn't read JaCoCo report file '{self._path}'.") from ex
1268 try:
1269 xmlDocument = parse(BytesIO(content), XMLParser(ns_clean=True), base_url=str(self._path))
1270 except XMLSyntaxError as ex:
1271 raise CodeCoverageError(f"XML syntax error in JaCoCo report file '{self._path}'.") from ex
1273 rootElement: _Element = xmlDocument.getroot()
1274 if rootElement.tag != "report":
1275 ex = CodeCoverageError(f"Root element of '{self._path}' is not '<report>'.")
1276 ex.add_note(f"Got root element '<{rootElement.tag}>'.")
1277 raise ex
1279 publicID = xmlDocument.docinfo.public_id
1280 match = PUBLIC_IDENTIFIER.fullmatch(publicID) if publicID is not None else None
1281 if match is None or match[1] not in [member.value for member in FormatVersion]:
1282 ex = CodeCoverageError(f"JaCoCo report file '{self._path}' states an unsupported format version.")
1283 ex.add_note(f"Got public identifier '{publicID}'." if publicID is not None else "Got no public identifier.")
1284 ex.add_note(f"Supported format versions: {', '.join(member.value for member in FormatVersion)}.")
1285 raise ex
1287 formatVersion = FormatVersion(match[1])
1288 schemaFile = SCHEMAS[formatVersion]
1289 try:
1290 schemaResourceFile = getResourceFile(Resources, schemaFile)
1291 except ToolingException as ex:
1292 raise CodeCoverageError(f"Couldn't locate XML Schema '{schemaFile}' in package resources.") from ex
1294 try:
1295 xmlSchema = XMLSchema(parse(schemaResourceFile, XMLParser(ns_clean=True)))
1296 except (XMLSyntaxError, XMLSchemaParseError) as ex:
1297 raise CodeCoverageError(f"Error while parsing XML Schema '{schemaFile}'.") from ex
1299 if not xmlSchema.validate(xmlDocument):
1300 ex = CodeCoverageError(f"Validation error for '{self._path}' using XSD schema '{schemaFile}'.")
1301 for logEntry in xmlSchema.error_log:
1302 ex.add_note(str(logEntry))
1303 raise ex
1305 self._xmlDocument = xmlDocument
1306 self._formatVersion = formatVersion
1308 self._analysisDuration = sw.Duration
1310 def Convert(self) -> None:
1311 """
1312 Convert the parsed and validated XML document to the format's model.
1314 :raises CodeCoverageError: If the XML file was not analyzed before. |br|
1315 Call 'Document.Analyze()' or create the document using
1316 'Document(path, analyzeAndConvert=True)'.
1317 :raises CodeCoverageError: If an element states a child element twice.
1318 """
1319 if self._xmlDocument is None:
1320 ex = CodeCoverageError(f"JaCoCo report file '{self._path}' needs to be read and analyzed by an XML parser.")
1321 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
1322 raise ex
1324 with Stopwatch() as sw:
1325 root: _Element = self._xmlDocument.getroot()
1326 self._name = root.attrib["name"]
1328 for sessionInfoElement in root.iterfind("sessioninfo"):
1329 SessionInfo.Parse(sessionInfoElement, parent=self)
1331 for groupElement in root.iterfind("group"):
1332 if (name := groupElement.attrib["name"]) in self._groups: 1332 ↛ 1333line 1332 didn't jump to line 1333 because the condition on line 1332 was never true
1333 raise CodeCoverageError(f"JaCoCo report '{self._name}' states group '{name}' twice.")
1335 Group.Parse(groupElement, parent=self)
1337 for packageElement in root.iterfind("package"):
1338 if (name := packageElement.attrib["name"]) in self._packages: 1338 ↛ 1339line 1338 didn't jump to line 1339 because the condition on line 1338 was never true
1339 raise CodeCoverageError(f"JaCoCo report '{self._name}' states package '{name}' twice.")
1341 Package.Parse(packageElement, parent=self)
1343 for counterElement in root.iterfind("counter"):
1344 if (counterType := CounterType(counterElement.attrib["type"])) in self._counters: 1344 ↛ 1345line 1344 didn't jump to line 1345 because the condition on line 1344 was never true
1345 raise CodeCoverageError(f"JaCoCo report '{self._name}' states counter '{counterType}' twice.")
1347 Counter.Parse(counterElement, parent=self)
1349 self._conversionDuration = sw.Duration
1351 def ToCoverageSummary(self) -> CoverageSummary:
1352 """
1353 Convert the format's model to the common model, and aggregate it.
1355 The packages of all groups are converted. A source file's path is its package's name and its name. A line's
1356 missed and covered branches become as many branches. A method spans its first line and the next lines of its file,
1357 as many as its ``LINE`` counter counts; a class spans its methods.
1359 :returns: The report's root of the common model, named after the report - or, if the report's
1360 name is empty, after the report file.
1361 :raises CodeCoverageError: If two packages of the same name state a source file of the same name.
1362 :raises CodeCoverageError: If a file's path runs through another file.
1363 """
1364 def iteratePackages(element: Report | Group) -> Generator[Package, None, None]:
1365 """
1366 Nested function for recursion.
1368 :param element: The report or a group.
1369 :returns: A generator of the packages of the element and of its groups.
1370 """
1371 for group in element._groups.values():
1372 yield from iteratePackages(group)
1374 yield from element._packages.values()
1376 def getStatus(element: CountersMixin, counterType: CounterType) -> LineCoverageStatus:
1377 """
1378 Nested function returning the coverage state of a class or method by its ``CLASS`` or ``METHOD`` counter.
1380 :param element: The class or method.
1381 :param counterType: The counter's kind.
1382 :returns: Covered, if the counter counts a covered item, otherwise uncovered; unknown, if the element
1383 has no such counter.
1384 """
1385 if (counter := element._counters.get(counterType)) is None: 1385 ↛ 1386line 1385 didn't jump to line 1386 because the condition on line 1385 was never true
1386 return LineCoverageStatus.Unknown
1388 return LineCoverageStatus.Covered if counter._coveredCount > 0 else LineCoverageStatus.Uncovered
1390 summary = CoverageSummary(self._name if self._name is not None and self._name != "" else self._path.stem)
1392 for package in iteratePackages(self):
1393 for sourceFile in package._sourceFiles.values():
1394 file = summary.GetOrAddFile(Path(package._name) / sourceFile._name)
1395 for number in sorted(sourceFile._lines):
1396 line = sourceFile._lines[number]
1397 missedBranchCount = line._missedBranchCount if line._missedBranchCount is not None else 0
1398 coveredBranchCount = line._coveredBranchCount if line._coveredBranchCount is not None else 0
1399 branches = [cc_Branch(LineCoverageStatus.Covered) for _ in range(coveredBranchCount)]
1400 branches.extend(cc_Branch(LineCoverageStatus.Uncovered) for _ in range(missedBranchCount))
1401 if line._coveredInstructionCount is None:
1402 status = LineCoverageStatus.Unknown
1403 elif line._coveredInstructionCount == 0:
1404 status = LineCoverageStatus.Uncovered
1405 elif missedBranchCount > 0:
1406 status = LineCoverageStatus.PartiallyCovered
1407 else:
1408 status = LineCoverageStatus.Covered
1409 cc_Line(number, status, None, branches, parent=file)
1411 for package in iteratePackages(self):
1412 parent: cc_Unit | CoverageSummary = summary
1413 for part in (part for part in package._name.split("/") if part != ""):
1414 parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent)
1416 for klass in package._classes.values():
1417 file = None
1418 if klass._sourceFileName is not None and klass._sourceFileName in package._sourceFiles:
1419 file = summary.GetOrAddFile(Path(package._name) / klass._sourceFileName)
1421 # per method with a first line in the file: its first and last line
1422 spans: dict[str, tuple[cc_Line, cc_Line]] = {}
1423 for key, method in klass._methods.items():
1424 if (
1425 file is None or method._lineNumber is None or method._lineNumber > file._lastLineNumber or
1426 (startLine := file._lines[method._lineNumber]) is None
1427 ):
1428 continue
1430 counter = method._counters.get(CounterType.Line)
1431 lineCount = counter._missedCount + counter._coveredCount if counter is not None else 1
1432 lines = list(islice(file.IterateLines(startLine), max(lineCount, 1)))
1433 spans[key] = (startLine, lines[-1])
1435 if (classUnit := parent._units.get(name := klass._name.rpartition("/")[2])) is None: 1435 ↛ 1445line 1435 didn't jump to line 1445 because the condition on line 1435 was always true
1436 classUnit = cc_Class(
1437 name,
1438 file=file,
1439 startLine=min((span[0] for span in spans.values()), key=lambda line: line._lineNumber, default=None),
1440 endLine=max((span[1] for span in spans.values()), key=lambda line: line._lineNumber, default=None),
1441 status=getStatus(klass, CounterType.Class),
1442 parent=parent
1443 )
1445 for key, method in klass._methods.items():
1446 if key not in classUnit._units: 1446 ↛ 1445line 1446 didn't jump to line 1445 because the condition on line 1446 was always true
1447 cc_Method(
1448 key,
1449 file=file,
1450 startLine=spans[key][0] if key in spans else None,
1451 endLine=spans[key][1] if key in spans else None,
1452 status=getStatus(method, CounterType.Method),
1453 parent=classUnit
1454 )
1456 summary.Aggregate()
1457 return summary