Coverage for pyEDAA/Reports/CodeCoverage/JaCoCo.py: 91%
599 statements
« prev ^ index » next coverage.py v7.17.3, created at 2026-11-14 02:26 +0000
« prev ^ index » next coverage.py v7.17.3, created at 2026-11-14 02:26 +0000
1# ==================================================================================================================== #
2# _____ ____ _ _ ____ _ #
3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
7# |_| |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Electronic Design Automation Abstraction (EDA²) #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
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, _Element, \
77 _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, CoverageSummary, \
86 Document as cc_Document, Line as cc_Line, LineCoverageStatus, \
87 Method as cc_Method, Package as cc_Package, Unit as cc_Unit
90__all__ = ["PUBLIC_IDENTIFIER", "SCHEMAS"]
92#: Pattern of the DTD's public identifier a report states, capturing the version, e.g. ``1.1``.
93PUBLIC_IDENTIFIER = re_compile(r"-//JACOCO//DTD Report (\S+)//EN")
95ParentType = TypeVar("ParentType", bound="Report | Group")
96"""A type variable for the parent of a :class:`Group` or :class:`Package`: a :class:`Report` or a :class:`Group`."""
99@export
100class FormatVersion(StringEnum):
101 """
102 Version of the JaCoCo XML format: the version of the DTD, whose public identifier a report states.
103 """
105 Version1_1 = "1.1" #: Format 1.1, public identifier ``-//JACOCO//DTD Report 1.1//EN``.
108# A class with a property named like a class - ``FormatVersion`` - can't name that class in the annotation of a field:
109# the class body's namespace, where annotations are evaluated, binds the name to the property.
110_FormatVersion = FormatVersion
112SCHEMAS: dict[FormatVersion, str] = {
113 FormatVersion.Version1_1: "JaCoCo-1.1.xsd"
114} #: Per format version, the XML schema a report of that version is validated against.
117@export
118class CounterType(StringEnum):
119 """
120 Kind of items a ``<counter>`` counts, as its ``type`` states.
121 """
123 Instruction = "INSTRUCTION" #: Java bytecode instructions.
124 Branch = "BRANCH" #: Branches of ``if`` and ``switch`` statements.
125 Line = "LINE" #: Source lines with at least one instruction.
126 Complexity = "COMPLEXITY" #: Cyclomatic complexity: paths, which in linear combination generate all paths.
127 Method = "METHOD" #: Methods - also constructors and static initializers - with at least one instruction.
128 Class = "CLASS" #: Classes with at least one method.
131@export
132class CountersMixin(metaclass=ExtendedType, mixin=True):
133 """
134 A mixin-class adding the counters an element states: per kind of items, how many were missed and covered.
135 """
137 _counters: dict[CounterType, Counter] #: The counters, by kind.
139 def __init__(self) -> None:
140 """
141 Initialize the counters to none.
143 A counter is added by creating it with the element as its parent.
144 """
145 self._counters = {}
147 @readonly
148 def Counters(self) -> dict[CounterType, Counter]:
149 """
150 Read-only property to access the counters (:attr:`_counters`).
152 :returns: The counters, by kind; a kind without items has no counter.
153 """
154 return self._counters
157@export
158@abstractclass
159class Base(metaclass=ExtendedType, slots=True):
160 """
161 Base-class of the named elements below the report.
163 Packages and classes are named elements.
164 """
166 _name: str #: Name of the element.
168 def __init__(self, name: str) -> None:
169 """
170 Initialize the name of the element.
172 :param name: Name of the element.
173 :raises ValueError: If parameter ``name`` is ``None``.
174 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
175 """
176 if name is None:
177 raise ValueError("Parameter 'name' is None.")
178 elif not isinstance(name, str):
179 ex = TypeError("Parameter 'name' is not of type 'str'.")
180 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
181 raise ex
183 self._name = name
185 @readonly
186 def Name(self) -> str:
187 """
188 Read-only property to access the name of the element (:attr:`_name`).
190 :returns: The name.
191 """
192 return self._name
195@export
196class Counter(metaclass=ExtendedType, slots=True):
197 """
198 A ``<counter>`` of an element: how many items of a kind were missed and covered.
199 """
201 _parent: Nullable[CountersMixin] #: The element the counter belongs to.
202 _type: CounterType #: Kind of items the counter counts.
203 _missedCount: int #: Number of missed items.
204 _coveredCount: int #: Number of covered items.
206 def __init__(
207 self,
208 counterType: CounterType,
209 missedCount: int,
210 coveredCount: int,
211 *,
212 parent: Nullable[CountersMixin] = None
213 ) -> None:
214 """
215 Initialize the counter, and add it to the counters of its element.
217 :param counterType: Kind of items the counter counts.
218 :param missedCount: Number of missed items.
219 :param coveredCount: Number of covered items.
220 :param parent: Optional, the element the counter belongs to; the counter is added to its counters by
221 :attr:`Type`. Default: ``None``.
222 :raises ValueError: If parameter ``counterType`` is ``None``.
223 :raises TypeError: If parameter ``counterType`` isn't of type :class:`CounterType`.
224 :raises ValueError: If parameter ``missedCount`` is ``None``.
225 :raises TypeError: If parameter ``missedCount`` isn't of type :class:`int`.
226 :raises ValueError: If parameter ``missedCount`` is negative.
227 :raises ValueError: If parameter ``coveredCount`` is ``None``.
228 :raises TypeError: If parameter ``coveredCount`` isn't of type :class:`int`.
229 :raises ValueError: If parameter ``coveredCount`` is negative.
230 :raises TypeError: If parameter ``parent`` isn't of type :class:`CountersMixin`.
231 """
232 if counterType is None: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true
233 raise ValueError("Parameter 'counterType' is None.")
234 elif not isinstance(counterType, CounterType):
235 ex = TypeError("Parameter 'counterType' is not of type 'CounterType'.")
236 ex.add_note(f"Got type '{getFullyQualifiedName(counterType)}'.")
237 raise ex
239 if missedCount is None:
240 raise ValueError("Parameter 'missedCount' is None.")
241 elif not isinstance(missedCount, int): 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true
242 ex = TypeError("Parameter 'missedCount' is not of type 'int'.")
243 ex.add_note(f"Got type '{getFullyQualifiedName(missedCount)}'.")
244 raise ex
245 elif missedCount < 0: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 ex = ValueError("Parameter 'missedCount' is negative.")
247 ex.add_note(f"Got value '{missedCount}'.")
248 raise ex
250 if coveredCount is None: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true
251 raise ValueError("Parameter 'coveredCount' is None.")
252 elif not isinstance(coveredCount, int): 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true
253 ex = TypeError("Parameter 'coveredCount' is not of type 'int'.")
254 ex.add_note(f"Got type '{getFullyQualifiedName(coveredCount)}'.")
255 raise ex
256 elif coveredCount < 0:
257 ex = ValueError("Parameter 'coveredCount' is negative.")
258 ex.add_note(f"Got value '{coveredCount}'.")
259 raise ex
261 if parent is not None and not isinstance(parent, CountersMixin):
262 ex = TypeError("Parameter 'parent' is not of type 'CountersMixin'.")
263 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
264 raise ex
266 self._parent = parent
267 self._type = counterType
268 self._missedCount = missedCount
269 self._coveredCount = coveredCount
271 if parent is not None: 271 ↛ exitline 271 didn't return from function '__init__' because the condition on line 271 was always true
272 parent._counters[self._type] = self
274 @classmethod
275 def Parse(cls, element: _Element, *, parent: Nullable[CountersMixin] = None) -> Self:
276 """
277 Parse a counter from its ``<counter>`` element.
279 :param element: The ``<counter>`` element.
280 :param parent: Optional, the element the counter belongs to. Default: ``None``.
281 :returns: The counter.
282 """
283 return cls(
284 CounterType(element.attrib["type"]),
285 int(element.attrib["missed"]),
286 int(element.attrib["covered"]),
287 parent=parent
288 )
290 @readonly
291 def Parent(self) -> Nullable[CountersMixin]:
292 """
293 Read-only property to access the element the counter belongs to (:attr:`_parent`).
295 :returns: The element; ``None`` if the counter belongs to no element.
296 """
297 return self._parent
299 @readonly
300 def Type(self) -> CounterType:
301 """
302 Read-only property to access the kind of items the counter counts (:attr:`_type`).
304 :returns: The kind of items.
305 """
306 return self._type
308 @readonly
309 def MissedCount(self) -> int:
310 """
311 Read-only property to access the number of missed items (:attr:`_missedCount`).
313 :returns: The number of missed items.
314 """
315 return self._missedCount
317 @readonly
318 def CoveredCount(self) -> int:
319 """
320 Read-only property to access the number of covered items (:attr:`_coveredCount`).
322 :returns: The number of covered items.
323 """
324 return self._coveredCount
327@export
328class SessionInfo(metaclass=ExtendedType, slots=True):
329 """
330 A ``<sessioninfo>`` of the report: a session, which contributed execution data - its ID, its start and its dump.
331 """
333 _parent: Nullable[Report] #: The report the session belongs to.
334 _sessionID: str #: ID of the session.
335 _start: datetime #: Time the session started.
336 _dump: datetime #: Time the session's execution data was dumped.
338 def __init__(self, sessionID: str, start: datetime, dump: datetime, *, parent: Nullable[Report] = None) -> None:
339 """
340 Initialize the session, and append it to the sessions of its report.
342 :param sessionID: ID of the session, e.g. the host's name and a random number.
343 :param start: Time the session started.
344 :param dump: Time the session's execution data was dumped.
345 :param parent: Optional, the report the session belongs to; the session is appended to its sessions.
346 Default: ``None``.
347 :raises ValueError: If parameter ``sessionID`` is ``None``.
348 :raises TypeError: If parameter ``sessionID`` isn't of type :class:`str`.
349 :raises ValueError: If parameter ``start`` is ``None``.
350 :raises TypeError: If parameter ``start`` isn't of type :class:`~datetime.datetime`.
351 :raises ValueError: If parameter ``dump`` is ``None``.
352 :raises TypeError: If parameter ``dump`` isn't of type :class:`~datetime.datetime`.
353 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report`.
354 """
355 if sessionID is None: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true
356 raise ValueError("Parameter 'sessionID' is None.")
357 elif not isinstance(sessionID, str): 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true
358 ex = TypeError("Parameter 'sessionID' is not of type 'str'.")
359 ex.add_note(f"Got type '{getFullyQualifiedName(sessionID)}'.")
360 raise ex
362 if start is None: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true
363 raise ValueError("Parameter 'start' is None.")
364 elif not isinstance(start, datetime):
365 ex = TypeError("Parameter 'start' is not of type 'datetime'.")
366 ex.add_note(f"Got type '{getFullyQualifiedName(start)}'.")
367 raise ex
369 if dump is None: 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true
370 raise ValueError("Parameter 'dump' is None.")
371 elif not isinstance(dump, datetime): 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true
372 ex = TypeError("Parameter 'dump' is not of type 'datetime'.")
373 ex.add_note(f"Got type '{getFullyQualifiedName(dump)}'.")
374 raise ex
376 if parent is not None and not isinstance(parent, Report):
377 ex = TypeError("Parameter 'parent' is not of type 'Report'.")
378 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
379 raise ex
381 self._parent = parent
382 self._sessionID = sessionID
383 self._start = start
384 self._dump = dump
386 if parent is not None:
387 parent._sessionInfos.append(self)
389 @classmethod
390 def Parse(cls, element: _Element, *, parent: Nullable[Report] = None) -> Self:
391 """
392 Parse a session from its ``<sessioninfo>`` element.
394 The times are stated in milliseconds since the epoch.
396 :param element: The ``<sessioninfo>`` element.
397 :param parent: Optional, the report the session belongs to. Default: ``None``.
398 :returns: The session.
399 """
400 epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
401 return cls(
402 element.attrib["id"],
403 epoch + timedelta(milliseconds=int(element.attrib["start"])),
404 epoch + timedelta(milliseconds=int(element.attrib["dump"])),
405 parent=parent
406 )
408 @readonly
409 def Parent(self) -> Nullable[Report]:
410 """
411 Read-only property to access the report the session belongs to (:attr:`_parent`).
413 :returns: The report; ``None`` if the session belongs to no report.
414 """
415 return self._parent
417 @readonly
418 def SessionID(self) -> str:
419 """
420 Read-only property to access the ID of the session (:attr:`_sessionID`).
422 JaCoCo's agent names a session by the host's name and a random number, e.g. ``dffa20ef8728-f9da5560``.
424 :returns: The ID.
425 """
426 return self._sessionID
428 @readonly
429 def Start(self) -> datetime:
430 """
431 Read-only property to access the time the session started (:attr:`_start`).
433 :returns: The time, with time zone UTC, if read from a report.
434 """
435 return self._start
437 @readonly
438 def Dump(self) -> datetime:
439 """
440 Read-only property to access the time the session's execution data was dumped (:attr:`_dump`).
442 :returns: The time, with time zone UTC, if read from a report.
443 """
444 return self._dump
447@export
448class Method(Base, CountersMixin):
449 """
450 A ``<method>`` of a class: its descriptor, its first line and its counters.
451 """
453 _parent: Nullable[Class] #: The class the method belongs to.
454 _descriptor: str #: Descriptor of the method: the types of its parameters and result, in VM notation.
455 _lineNumber: Nullable[int] #: The method's first source line, if the report says.
457 def __init__(
458 self,
459 name: str,
460 descriptor: str,
461 lineNumber: Nullable[int] = None,
462 *,
463 parent: Nullable[Class] = None
464 ) -> None:
465 """
466 Initialize the method, and add it to the methods of its class.
468 Its counters are added by creating them with this method as their parent.
470 :param name: Name of the method, e.g. ``absolute``; ``<init>`` for a constructor.
471 :param descriptor: Descriptor of the method, e.g. ``(I)I`` for a method taking and returning an ``int``.
472 :param lineNumber: Optional, the method's first source line. Default: ``None``.
473 :param parent: Optional, the class the method belongs to; the method is added to its methods by
474 :attr:`Name` and :attr:`Descriptor`. Default: ``None``.
475 :raises ValueError: If parameter ``name`` is ``None``.
476 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
477 :raises ValueError: If parameter ``name`` is empty.
478 :raises ValueError: If parameter ``descriptor`` is ``None``.
479 :raises TypeError: If parameter ``descriptor`` isn't of type :class:`str`.
480 :raises ValueError: If parameter ``descriptor`` is empty.
481 :raises TypeError: If parameter ``lineNumber`` isn't of type :class:`int`.
482 :raises ValueError: If parameter ``lineNumber`` is less than 1.
483 :raises TypeError: If parameter ``parent`` isn't of type :class:`Class`.
484 """
485 super().__init__(name)
486 CountersMixin.__init__(self)
488 if name == "": 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true
489 raise ValueError("Parameter 'name' is empty.")
491 if descriptor is None:
492 raise ValueError("Parameter 'descriptor' is None.")
493 elif not isinstance(descriptor, str): 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true
494 ex = TypeError("Parameter 'descriptor' is not of type 'str'.")
495 ex.add_note(f"Got type '{getFullyQualifiedName(descriptor)}'.")
496 raise ex
497 elif descriptor == "":
498 raise ValueError("Parameter 'descriptor' is empty.")
500 if lineNumber is not None:
501 if not isinstance(lineNumber, int): 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true
502 ex = TypeError("Parameter 'lineNumber' is not of type 'int'.")
503 ex.add_note(f"Got type '{getFullyQualifiedName(lineNumber)}'.")
504 raise ex
505 elif lineNumber < 1:
506 ex = ValueError("Parameter 'lineNumber' is less than 1.")
507 ex.add_note(f"Got value '{lineNumber}'.")
508 raise ex
510 if parent is not None and not isinstance(parent, Class):
511 ex = TypeError("Parameter 'parent' is not of type 'Class'.")
512 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
513 raise ex
515 self._parent = parent
516 self._descriptor = descriptor
517 self._lineNumber = lineNumber
519 if parent is not None:
520 parent._methods[f"{self._name}{self._descriptor}"] = self
522 @classmethod
523 def Parse(cls, element: _Element, *, parent: Nullable[Class] = None) -> Self:
524 """
525 Parse a method and its counters from its ``<method>`` element.
527 :param element: The ``<method>`` element.
528 :param parent: Optional, the class the method belongs to. Default: ``None``.
529 :returns: The method.
530 :raises CodeCoverageError: If the method states a counter twice.
531 """
532 method = cls(
533 element.attrib["name"],
534 element.attrib["desc"],
535 int(line) if (line := element.attrib.get("line")) is not None else None,
536 parent=parent
537 )
539 for counterElement in element.iterfind("counter"):
540 if (counterType := CounterType(counterElement.attrib["type"])) in method._counters:
541 raise CodeCoverageError(
542 f"JaCoCo method '{method._name}{method._descriptor}' states counter '{counterType}' twice."
543 )
545 Counter.Parse(counterElement, parent=method)
547 return method
549 @readonly
550 def Parent(self) -> Nullable[Class]:
551 """
552 Read-only property to access the class the method belongs to (:attr:`_parent`).
554 :returns: The class; ``None`` if the method belongs to no class.
555 """
556 return self._parent
558 @readonly
559 def Descriptor(self) -> str:
560 """
561 Read-only property to access the descriptor of the method (:attr:`_descriptor`).
563 The descriptor ``([Ljava/lang/String;)V`` is of a method taking a ``String[]`` and returning ``void``.
565 :returns: The descriptor, in VM notation.
566 """
567 return self._descriptor
569 @readonly
570 def LineNumber(self) -> Nullable[int]:
571 """
572 Read-only property to access the method's first source line (:attr:`_lineNumber`).
574 A method of a class compiled without debug information has none.
576 :returns: The line number, or ``None`` if the report doesn't say.
577 """
578 return self._lineNumber
581@export
582class Class(Base, CountersMixin):
583 """
584 A ``<class>`` of a package: its source file, its methods and its counters.
585 """
587 _parent: Nullable[Package] #: The package the class belongs to.
588 _sourceFileName: Nullable[str] #: Name of the class' source file, if the report says.
589 _methods: dict[str, Method] #: The methods, by name and descriptor.
591 def __init__(self, name: str, sourceFileName: Nullable[str] = None, *, parent: Nullable[Package] = None) -> None:
592 """
593 Initialize the class, and add it to the classes of its package.
595 Its methods and counters are added by creating them with this class as their parent.
597 :param name: Fully qualified name of the class in VM notation, e.g. ``my/pack/MyClass``.
598 :param sourceFileName: Optional, name of the class' source file, e.g. ``MyClass.java``. Default: ``None``.
599 :param parent: Optional, the package the class belongs to; the class is added to its classes by
600 :attr:`Name`. Default: ``None``.
601 :raises ValueError: If parameter ``name`` is ``None``.
602 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
603 :raises ValueError: If parameter ``name`` is empty.
604 :raises TypeError: If parameter ``sourceFileName`` isn't of type :class:`str`.
605 :raises TypeError: If parameter ``parent`` isn't of type :class:`Package`.
606 """
607 super().__init__(name)
608 CountersMixin.__init__(self)
610 if name == "":
611 raise ValueError("Parameter 'name' is empty.")
613 if sourceFileName is not None and not isinstance(sourceFileName, str):
614 ex = TypeError("Parameter 'sourceFileName' is not of type 'str'.")
615 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFileName)}'.")
616 raise ex
618 if parent is not None and not isinstance(parent, Package):
619 ex = TypeError("Parameter 'parent' is not of type 'Package'.")
620 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
621 raise ex
623 self._parent = parent
624 self._sourceFileName = sourceFileName
625 self._methods = {}
627 if parent is not None:
628 parent._classes[self._name] = self
630 @classmethod
631 def Parse(cls, element: _Element, *, parent: Nullable[Package] = None) -> Self:
632 """
633 Parse a class, its methods and counters from its ``<class>`` element.
635 :param element: The ``<class>`` element.
636 :param parent: Optional, the package the class belongs to. Default: ``None``.
637 :returns: The class.
638 :raises CodeCoverageError: If the class states a method twice.
639 :raises CodeCoverageError: If the class states a counter twice.
640 """
641 klass = cls(element.attrib["name"], element.attrib.get("sourcefilename"), parent=parent)
643 for methodElement in element.iterfind("method"):
644 if (key := f"{methodElement.attrib['name']}{methodElement.attrib['desc']}") in klass._methods:
645 raise CodeCoverageError(f"JaCoCo class '{klass._name}' states method '{key}' twice.")
647 Method.Parse(methodElement, parent=klass)
649 for counterElement in element.iterfind("counter"):
650 if (counterType := CounterType(counterElement.attrib["type"])) in klass._counters: 650 ↛ 651line 650 didn't jump to line 651 because the condition on line 650 was never true
651 raise CodeCoverageError(f"JaCoCo class '{klass._name}' states counter '{counterType}' twice.")
653 Counter.Parse(counterElement, parent=klass)
655 return klass
657 @readonly
658 def Parent(self) -> Nullable[Package]:
659 """
660 Read-only property to access the package the class belongs to (:attr:`_parent`).
662 :returns: The package; ``None`` if the class belongs to no package.
663 """
664 return self._parent
666 @readonly
667 def SourceFileName(self) -> Nullable[str]:
668 """
669 Read-only property to access the name of the class' source file (:attr:`_sourceFileName`).
671 A class compiled without debug information names no source file.
673 :returns: The name of a :class:`SourceFile` of the class' package, or ``None`` if the report doesn't say.
674 """
675 return self._sourceFileName
677 @readonly
678 def Methods(self) -> dict[str, Method]:
679 """
680 Read-only property to access the methods (:attr:`_methods`).
682 A method is keyed by its name and descriptor, e.g. ``absolute(I)I``.
684 :returns: The methods, by name and descriptor.
685 """
686 return self._methods
689@export
690class Line(metaclass=ExtendedType, slots=True):
691 """
692 A ``<line>`` of a source file: its missed and covered instructions and branches.
693 """
695 _parent: Nullable[SourceFile] #: The source file the line belongs to.
696 _lineNumber: int #: Line number, counted from 1.
697 _missedInstructionCount: Nullable[int] #: Number of missed instructions, if the report says.
698 _coveredInstructionCount: Nullable[int] #: Number of covered instructions, if the report says.
699 _missedBranchCount: Nullable[int] #: Number of missed branches, if the report says.
700 _coveredBranchCount: Nullable[int] #: Number of covered branches, if the report says.
702 def __init__(
703 self,
704 lineNumber: int,
705 missedInstructionCount: Nullable[int] = None,
706 coveredInstructionCount: Nullable[int] = None,
707 missedBranchCount: Nullable[int] = None,
708 coveredBranchCount: Nullable[int] = None,
709 *,
710 parent: Nullable[SourceFile] = None
711 ) -> None:
712 """
713 Initialize the line, and add it to the lines of its source file.
715 :param lineNumber: Line number, counted from 1.
716 :param missedInstructionCount: Optional, number of missed instructions. Default: ``None``.
717 :param coveredInstructionCount: Optional, number of covered instructions. Default: ``None``.
718 :param missedBranchCount: Optional, number of missed branches. Default: ``None``.
719 :param coveredBranchCount: Optional, number of covered branches. Default: ``None``.
720 :param parent: Optional, the source file the line belongs to; the line is added to its lines by
721 :attr:`LineNumber`. Default: ``None``.
722 :raises ValueError: If parameter ``lineNumber`` is ``None``.
723 :raises TypeError: If parameter ``lineNumber`` isn't of type :class:`int`.
724 :raises ValueError: If parameter ``lineNumber`` is less than 1.
725 :raises TypeError: If parameter ``missedInstructionCount`` isn't of type :class:`int`.
726 :raises ValueError: If parameter ``missedInstructionCount`` is negative.
727 :raises TypeError: If parameter ``coveredInstructionCount`` isn't of type :class:`int`.
728 :raises ValueError: If parameter ``coveredInstructionCount`` is negative.
729 :raises TypeError: If parameter ``missedBranchCount`` isn't of type :class:`int`.
730 :raises ValueError: If parameter ``missedBranchCount`` is negative.
731 :raises TypeError: If parameter ``coveredBranchCount`` isn't of type :class:`int`.
732 :raises ValueError: If parameter ``coveredBranchCount`` is negative.
733 :raises TypeError: If parameter ``parent`` isn't of type :class:`SourceFile`.
734 """
735 if lineNumber is None:
736 raise ValueError("Parameter 'lineNumber' is None.")
737 elif not isinstance(lineNumber, int):
738 ex = TypeError("Parameter 'lineNumber' is not of type 'int'.")
739 ex.add_note(f"Got type '{getFullyQualifiedName(lineNumber)}'.")
740 raise ex
741 elif lineNumber < 1:
742 ex = ValueError("Parameter 'lineNumber' is less than 1.")
743 ex.add_note(f"Got value '{lineNumber}'.")
744 raise ex
746 for name, count in (
747 ("missedInstructionCount", missedInstructionCount),
748 ("coveredInstructionCount", coveredInstructionCount),
749 ("missedBranchCount", missedBranchCount),
750 ("coveredBranchCount", coveredBranchCount)
751 ):
752 if count is not None:
753 if not isinstance(count, int):
754 ex = TypeError(f"Parameter '{name}' is not of type 'int'.")
755 ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.")
756 raise ex
757 elif count < 0:
758 ex = ValueError(f"Parameter '{name}' is negative.")
759 ex.add_note(f"Got value '{count}'.")
760 raise ex
762 if parent is not None and not isinstance(parent, SourceFile):
763 ex = TypeError("Parameter 'parent' is not of type 'SourceFile'.")
764 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
765 raise ex
767 self._parent = parent
768 self._lineNumber = lineNumber
769 self._missedInstructionCount = missedInstructionCount
770 self._coveredInstructionCount = coveredInstructionCount
771 self._missedBranchCount = missedBranchCount
772 self._coveredBranchCount = coveredBranchCount
774 if parent is not None:
775 parent._lines[self._lineNumber] = self
777 @classmethod
778 def Parse(cls, element: _Element, *, parent: Nullable[SourceFile] = None) -> Self:
779 """
780 Parse a line from its ``<line>`` element.
782 :param element: The ``<line>`` element.
783 :param parent: Optional, the source file the line belongs to. Default: ``None``.
784 :returns: The line.
785 """
786 attributes = element.attrib
787 return cls(
788 int(attributes["nr"]),
789 int(attributes["mi"]) if "mi" in attributes else None,
790 int(attributes["ci"]) if "ci" in attributes else None,
791 int(attributes["mb"]) if "mb" in attributes else None,
792 int(attributes["cb"]) if "cb" in attributes else None,
793 parent=parent
794 )
796 @readonly
797 def Parent(self) -> Nullable[SourceFile]:
798 """
799 Read-only property to access the source file the line belongs to (:attr:`_parent`).
801 :returns: The source file; ``None`` if the line belongs to no source file.
802 """
803 return self._parent
805 @readonly
806 def LineNumber(self) -> int:
807 """
808 Read-only property to access the line number (:attr:`_lineNumber`).
810 :returns: The line number, counted from 1.
811 """
812 return self._lineNumber
814 @readonly
815 def MissedInstructionCount(self) -> Nullable[int]:
816 """
817 Read-only property to access the number of missed instructions (:attr:`_missedInstructionCount`).
819 :returns: The number, or ``None`` if the report doesn't say.
820 """
821 return self._missedInstructionCount
823 @readonly
824 def CoveredInstructionCount(self) -> Nullable[int]:
825 """
826 Read-only property to access the number of covered instructions (:attr:`_coveredInstructionCount`).
828 :returns: The number, or ``None`` if the report doesn't say.
829 """
830 return self._coveredInstructionCount
832 @readonly
833 def MissedBranchCount(self) -> Nullable[int]:
834 """
835 Read-only property to access the number of missed branches (:attr:`_missedBranchCount`).
837 :returns: The number, or ``None`` if the report doesn't say.
838 """
839 return self._missedBranchCount
841 @readonly
842 def CoveredBranchCount(self) -> Nullable[int]:
843 """
844 Read-only property to access the number of covered branches (:attr:`_coveredBranchCount`).
846 :returns: The number, or ``None`` if the report doesn't say.
847 """
848 return self._coveredBranchCount
851@export
852class SourceFile(Base, CountersMixin):
853 """
854 A ``<sourcefile>`` of a package: its lines and its counters.
855 """
857 _parent: Nullable[Package] #: The package the source file belongs to.
858 _lines: dict[int, Line] #: The lines with instructions, by number.
860 def __init__(self, name: str, *, parent: Nullable[Package] = None) -> None:
861 """
862 Initialize the source file, and add it to the source files of its package.
864 Its lines and counters are added by creating them with this source file as their parent.
866 :param name: Name of the source file, without the package's directory, e.g. ``MyClass.java``.
867 :param parent: Optional, the package the source file belongs to; the source file is added to its source
868 files by :attr:`Name`. Default: ``None``.
869 :raises ValueError: If parameter ``name`` is ``None``.
870 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
871 :raises ValueError: If parameter ``name`` is empty.
872 :raises TypeError: If parameter ``parent`` isn't of type :class:`Package`.
873 """
874 super().__init__(name)
875 CountersMixin.__init__(self)
877 if name == "":
878 raise ValueError("Parameter 'name' is empty.")
880 if parent is not None and not isinstance(parent, Package):
881 ex = TypeError("Parameter 'parent' is not of type 'Package'.")
882 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
883 raise ex
885 self._parent = parent
886 self._lines = {}
888 if parent is not None:
889 parent._sourceFiles[self._name] = self
891 @classmethod
892 def Parse(cls, element: _Element, *, parent: Nullable[Package] = None) -> Self:
893 """
894 Parse a source file, its lines and counters from its ``<sourcefile>`` element.
896 :param element: The ``<sourcefile>`` element.
897 :param parent: Optional, the package the source file belongs to. Default: ``None``.
898 :returns: The source file.
899 :raises CodeCoverageError: If the source file states a line twice.
900 :raises CodeCoverageError: If the source file states a counter twice.
901 """
902 sourceFile = cls(element.attrib["name"], parent=parent)
904 for lineElement in element.iterfind("line"):
905 if (lineNumber := int(lineElement.attrib["nr"])) in sourceFile._lines:
906 raise CodeCoverageError(f"JaCoCo source file '{sourceFile._name}' states line {lineNumber} twice.")
908 Line.Parse(lineElement, parent=sourceFile)
910 for counterElement in element.iterfind("counter"):
911 if (counterType := CounterType(counterElement.attrib["type"])) in sourceFile._counters: 911 ↛ 912line 911 didn't jump to line 912 because the condition on line 911 was never true
912 raise CodeCoverageError(f"JaCoCo source file '{sourceFile._name}' states counter '{counterType}' twice.")
914 Counter.Parse(counterElement, parent=sourceFile)
916 return sourceFile
918 @readonly
919 def Parent(self) -> Nullable[Package]:
920 """
921 Read-only property to access the package the source file belongs to (:attr:`_parent`).
923 :returns: The package; ``None`` if the source file belongs to no package.
924 """
925 return self._parent
927 @readonly
928 def Lines(self) -> dict[int, Line]:
929 """
930 Read-only property to access the lines with instructions (:attr:`_lines`).
932 :returns: The lines, by number.
933 """
934 return self._lines
937@export
938class Package(Base, CountersMixin, Generic[ParentType]):
939 """
940 A ``<package>`` of the report or of a group: its classes and source files, and its counters.
941 """
943 _parent: Nullable[ParentType] #: The report or group the package belongs to.
944 _classes: dict[str, Class] #: The classes, by name.
945 _sourceFiles: dict[str, SourceFile] #: The source files, by name.
947 def __init__(self, name: str, *, parent: Nullable[ParentType] = None) -> None:
948 """
949 Initialize the package, and add it to the packages of its report or group.
951 Its classes, source files and counters are added by creating them with this package as their parent.
953 :param name: Name of the package in VM notation, e.g. ``my/pack``; empty for the default package.
954 :param parent: Optional, the report or group the package belongs to; the package is added to its packages
955 by :attr:`Name`. Default: ``None``.
956 :raises ValueError: If parameter ``name`` is ``None``.
957 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
958 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report` or :class:`Group`.
959 """
960 super().__init__(name)
961 CountersMixin.__init__(self)
963 if parent is not None and not isinstance(parent, (Report, Group)):
964 ex = TypeError("Parameter 'parent' is not of type 'Report' or 'Group'.")
965 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
966 raise ex
968 self._parent = parent
969 self._classes = {}
970 self._sourceFiles = {}
972 if parent is not None:
973 parent._packages[self._name] = self
975 @classmethod
976 def Parse(cls, element: _Element, *, parent: Nullable[ParentType] = None) -> Self:
977 """
978 Parse a package, its classes, source files and counters from its ``<package>`` element.
980 :param element: The ``<package>`` element.
981 :param parent: Optional, the report or group the package belongs to. Default: ``None``.
982 :returns: The package.
983 :raises CodeCoverageError: If the package states a class or source file twice.
984 :raises CodeCoverageError: If the package states a counter twice.
985 """
986 package = cls(element.attrib["name"], parent=parent)
988 for classElement in element.iterfind("class"):
989 if (name := classElement.attrib["name"]) in package._classes:
990 raise CodeCoverageError(f"JaCoCo package '{package._name}' states class '{name}' twice.")
992 Class.Parse(classElement, parent=package)
994 for sourceFileElement in element.iterfind("sourcefile"):
995 if (name := sourceFileElement.attrib["name"]) in package._sourceFiles: 995 ↛ 996line 995 didn't jump to line 996 because the condition on line 995 was never true
996 raise CodeCoverageError(f"JaCoCo package '{package._name}' states source file '{name}' twice.")
998 SourceFile.Parse(sourceFileElement, parent=package)
1000 for counterElement in element.iterfind("counter"):
1001 if (counterType := CounterType(counterElement.attrib["type"])) in package._counters: 1001 ↛ 1002line 1001 didn't jump to line 1002 because the condition on line 1001 was never true
1002 raise CodeCoverageError(f"JaCoCo package '{package._name}' states counter '{counterType}' twice.")
1004 Counter.Parse(counterElement, parent=package)
1006 return package
1008 @readonly
1009 def Parent(self) -> Nullable[ParentType]:
1010 """
1011 Read-only property to access the report or group the package belongs to (:attr:`_parent`).
1013 :returns: The :class:`Report` or :class:`Group`; ``None`` if the package belongs to none.
1014 """
1015 return self._parent
1017 @readonly
1018 def Classes(self) -> dict[str, Class]:
1019 """
1020 Read-only property to access the classes (:attr:`_classes`).
1022 A class is keyed by its name, e.g. ``my/pack/MyClass``, a nested class by its binary name, e.g.
1023 ``my/pack/MyClass$Inner``.
1025 :returns: The classes, by name.
1026 """
1027 return self._classes
1029 @readonly
1030 def SourceFiles(self) -> dict[str, SourceFile]:
1031 """
1032 Read-only property to access the source files (:attr:`_sourceFiles`).
1034 A source file is keyed by its name, e.g. ``MyClass.java``.
1036 :returns: The source files, by name.
1037 """
1038 return self._sourceFiles
1041@export
1042class Group(Base, CountersMixin, Generic[ParentType]):
1043 """
1044 A ``<group>`` of the report or of a group: its groups or packages, and its counters.
1046 JaCoCo groups packages, e.g. by module, if Maven's goal ``report-aggregate`` or Ant's task ``report`` asks for it.
1047 """
1049 _parent: Nullable[ParentType] #: The report or group the group belongs to.
1050 _groups: dict[str, Group[Group]] #: The groups, by name.
1051 _packages: dict[str, Package[Group]] #: The packages, by name.
1053 def __init__(self, name: str, *, parent: Nullable[ParentType] = None) -> None:
1054 """
1055 Initialize the group, and add it to the groups of its report or group.
1057 Its groups, packages and counters are added by creating them with this group as their parent.
1059 :param name: Name of the group, e.g. of a module.
1060 :param parent: Optional, the report or group the group belongs to; the group is added to its groups by
1061 :attr:`Name`. Default: ``None``.
1062 :raises ValueError: If parameter ``name`` is ``None``.
1063 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
1064 :raises ValueError: If parameter ``name`` is empty.
1065 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report` or :class:`Group`.
1066 """
1067 super().__init__(name)
1068 CountersMixin.__init__(self)
1070 if name == "":
1071 raise ValueError("Parameter 'name' is empty.")
1073 if parent is not None and not isinstance(parent, (Report, Group)):
1074 ex = TypeError("Parameter 'parent' is not of type 'Report' or 'Group'.")
1075 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
1076 raise ex
1078 self._parent = parent
1079 self._groups = {}
1080 self._packages = {}
1082 if parent is not None:
1083 parent._groups[self._name] = self
1085 @classmethod
1086 def Parse(cls, element: _Element, *, parent: Nullable[ParentType] = None) -> Self:
1087 """
1088 Parse a group, its groups, packages and counters from its ``<group>`` element.
1090 :param element: The ``<group>`` element.
1091 :param parent: Optional, the report or group the group belongs to. Default: ``None``.
1092 :returns: The group.
1093 :raises CodeCoverageError: If the group states a group or package twice.
1094 :raises CodeCoverageError: If the group states a counter twice.
1095 """
1096 group = cls(element.attrib["name"], parent=parent)
1098 for groupElement in element.iterfind("group"):
1099 if (name := groupElement.attrib["name"]) in group._groups: 1099 ↛ 1100line 1099 didn't jump to line 1100 because the condition on line 1099 was never true
1100 raise CodeCoverageError(f"JaCoCo group '{group._name}' states group '{name}' twice.")
1102 Group.Parse(groupElement, parent=group)
1104 for packageElement in element.iterfind("package"):
1105 if (name := packageElement.attrib["name"]) in group._packages:
1106 raise CodeCoverageError(f"JaCoCo group '{group._name}' states package '{name}' twice.")
1108 Package.Parse(packageElement, parent=group)
1110 for counterElement in element.iterfind("counter"):
1111 if (counterType := CounterType(counterElement.attrib["type"])) in group._counters: 1111 ↛ 1112line 1111 didn't jump to line 1112 because the condition on line 1111 was never true
1112 raise CodeCoverageError(f"JaCoCo group '{group._name}' states counter '{counterType}' twice.")
1114 Counter.Parse(counterElement, parent=group)
1116 return group
1118 @readonly
1119 def Parent(self) -> Nullable[ParentType]:
1120 """
1121 Read-only property to access the report or group the group belongs to (:attr:`_parent`).
1123 :returns: The :class:`Report` or :class:`Group`; ``None`` if the group belongs to none.
1124 """
1125 return self._parent
1127 @readonly
1128 def Groups(self) -> dict[str, Group[Group]]:
1129 """
1130 Read-only property to access the groups (:attr:`_groups`).
1132 :returns: The groups, by name.
1133 """
1134 return self._groups
1136 @readonly
1137 def Packages(self) -> dict[str, Package[Group]]:
1138 """
1139 Read-only property to access the packages (:attr:`_packages`).
1141 :returns: The packages, by name.
1142 """
1143 return self._packages
1146@export
1147class Report(CountersMixin, mixin=True):
1148 """
1149 The root element ``<report>``: its name, the sessions, the groups or packages, and the counters.
1151 The root of the format's model: a session, a group, a package or a counter names it as its parent.
1152 """
1154 _name: Nullable[str] #: Name of the report, e.g. of the project measured.
1155 _formatVersion: Nullable[_FormatVersion] #: Version of the report format.
1156 _sessionInfos: list[SessionInfo] #: The sessions, which contributed execution data.
1157 _groups: dict[str, Group[Report]] #: The groups, by name.
1158 _packages: dict[str, Package[Report]] #: The packages, by name.
1160 def __init__(self) -> None:
1161 """
1162 Initialize an empty report.
1163 """
1164 CountersMixin.__init__(self)
1166 self._name = None
1167 self._formatVersion = None
1168 self._sessionInfos = []
1169 self._groups = {}
1170 self._packages = {}
1172 @readonly
1173 def Name(self) -> Nullable[str]:
1174 """
1175 Read-only property to access the name of the report (:attr:`_name`).
1177 The name is chosen by the tool writing the report, e.g. the name of the project measured.
1179 :returns: The name; ``None`` before the report was converted.
1180 """
1181 return self._name
1183 @readonly
1184 def FormatVersion(self) -> Nullable[FormatVersion]:
1185 """
1186 Read-only property to access the version of the report format (:attr:`_formatVersion`).
1188 :returns: The format version; ``None`` before the report was analyzed.
1189 """
1190 return self._formatVersion
1192 @readonly
1193 def SessionInfos(self) -> list[SessionInfo]:
1194 """
1195 Read-only property to access the sessions, which contributed execution data (:attr:`_sessionInfos`).
1197 :returns: The sessions, in the order the report lists them.
1198 """
1199 return self._sessionInfos
1201 @readonly
1202 def Groups(self) -> dict[str, Group[Report]]:
1203 """
1204 Read-only property to access the groups (:attr:`_groups`).
1206 :returns: The groups, by name; empty, if the report has packages.
1207 """
1208 return self._groups
1210 @readonly
1211 def Packages(self) -> dict[str, Package[Report]]:
1212 """
1213 Read-only property to access the packages (:attr:`_packages`).
1215 :returns: The packages, by name; empty, if the report has groups.
1216 """
1217 return self._packages
1220@export
1221class Document(cc_Document, Report):
1222 """
1223 A JaCoCo XML code coverage report: read into the format's model, and converted to the common model.
1224 """
1226 _xmlDocument: Nullable[_ElementTree] #: The parsed and validated XML document, after :meth:`Analyze`.
1228 def __init__(self, xmlReportFile: Path, analyzeAndConvert: bool = False) -> None:
1229 """
1230 Initialize the report, and optionally read it.
1232 :param xmlReportFile: Path to the JaCoCo XML file.
1233 :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
1234 """
1235 super().__init__(xmlReportFile)
1236 Report.__init__(self)
1238 self._xmlDocument = None
1240 if analyzeAndConvert:
1241 self.Analyze()
1242 self.Convert()
1244 def Analyze(self) -> None:
1245 """
1246 Parse the XML file, read the format version from the DTD's public identifier, and validate the file against the
1247 XML schema of that version (:data:`SCHEMAS`).
1249 :raises CodeCoverageError: If the file doesn't exist.
1250 :raises CodeCoverageError: If the file can't be read.
1251 :raises CodeCoverageError: If the file isn't well-formed XML.
1252 :raises CodeCoverageError: If the root element isn't ``<report>``.
1253 :raises CodeCoverageError: If the file states no public identifier of a supported format version.
1254 :raises CodeCoverageError: If the XML schema can't be located or parsed.
1255 :raises CodeCoverageError: If the file isn't valid according to the XML schema.
1256 """
1257 if not self._path.exists():
1258 raise CodeCoverageError(f"JaCoCo report file '{self._path}' does not exist.") \
1259 from FileNotFoundError(f"File '{self._path}' not found.")
1261 with Stopwatch() as sw:
1262 try:
1263 content = self._path.read_bytes()
1264 except OSError as ex:
1265 raise CodeCoverageError(f"Couldn't read JaCoCo report file '{self._path}'.") from ex
1267 try:
1268 xmlDocument = parse(BytesIO(content), XMLParser(ns_clean=True), base_url=str(self._path))
1269 except XMLSyntaxError as ex:
1270 raise CodeCoverageError(f"XML syntax error in JaCoCo report file '{self._path}'.") from ex
1272 rootElement: _Element = xmlDocument.getroot()
1273 if rootElement.tag != "report":
1274 ex = CodeCoverageError(f"Root element of '{self._path}' is not '<report>'.")
1275 ex.add_note(f"Got root element '<{rootElement.tag}>'.")
1276 raise ex
1278 publicID = xmlDocument.docinfo.public_id
1279 match = PUBLIC_IDENTIFIER.fullmatch(publicID) if publicID is not None else None
1280 if match is None or match[1] not in [member.value for member in FormatVersion]:
1281 ex = CodeCoverageError(f"JaCoCo report file '{self._path}' states an unsupported format version.")
1282 ex.add_note(f"Got public identifier '{publicID}'." if publicID is not None else "Got no public identifier.")
1283 ex.add_note(f"Supported format versions: {', '.join(member.value for member in FormatVersion)}.")
1284 raise ex
1286 formatVersion = FormatVersion(match[1])
1287 schemaFile = SCHEMAS[formatVersion]
1288 try:
1289 schemaResourceFile = getResourceFile(Resources, schemaFile)
1290 except ToolingException as ex:
1291 raise CodeCoverageError(f"Couldn't locate XML Schema '{schemaFile}' in package resources.") from ex
1293 try:
1294 xmlSchema = XMLSchema(parse(schemaResourceFile, XMLParser(ns_clean=True)))
1295 except (XMLSyntaxError, XMLSchemaParseError) as ex:
1296 raise CodeCoverageError(f"Error while parsing XML Schema '{schemaFile}'.") from ex
1298 if not xmlSchema.validate(xmlDocument):
1299 ex = CodeCoverageError(f"Validation error for '{self._path}' using XSD schema '{schemaFile}'.")
1300 for logEntry in xmlSchema.error_log:
1301 ex.add_note(str(logEntry))
1302 raise ex
1304 self._xmlDocument = xmlDocument
1305 self._formatVersion = formatVersion
1307 self._analysisDuration = sw.Duration
1309 def Convert(self) -> None:
1310 """
1311 Convert the parsed and validated XML document to the format's model.
1313 :raises CodeCoverageError: If the XML file was not analyzed before. |br|
1314 Call 'Document.Analyze()' or create the document using
1315 'Document(path, analyzeAndConvert=True)'.
1316 :raises CodeCoverageError: If an element states a child element twice.
1317 """
1318 if self._xmlDocument is None:
1319 ex = CodeCoverageError(f"JaCoCo report file '{self._path}' needs to be read and analyzed by an XML parser.")
1320 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
1321 raise ex
1323 with Stopwatch() as sw:
1324 root: _Element = self._xmlDocument.getroot()
1325 self._name = root.attrib["name"]
1327 for sessionInfoElement in root.iterfind("sessioninfo"):
1328 SessionInfo.Parse(sessionInfoElement, parent=self)
1330 for groupElement in root.iterfind("group"):
1331 if (name := groupElement.attrib["name"]) in self._groups: 1331 ↛ 1332line 1331 didn't jump to line 1332 because the condition on line 1331 was never true
1332 raise CodeCoverageError(f"JaCoCo report '{self._name}' states group '{name}' twice.")
1334 Group.Parse(groupElement, parent=self)
1336 for packageElement in root.iterfind("package"):
1337 if (name := packageElement.attrib["name"]) in self._packages: 1337 ↛ 1338line 1337 didn't jump to line 1338 because the condition on line 1337 was never true
1338 raise CodeCoverageError(f"JaCoCo report '{self._name}' states package '{name}' twice.")
1340 Package.Parse(packageElement, parent=self)
1342 for counterElement in root.iterfind("counter"):
1343 if (counterType := CounterType(counterElement.attrib["type"])) in self._counters: 1343 ↛ 1344line 1343 didn't jump to line 1344 because the condition on line 1343 was never true
1344 raise CodeCoverageError(f"JaCoCo report '{self._name}' states counter '{counterType}' twice.")
1346 Counter.Parse(counterElement, parent=self)
1348 self._conversionDuration = sw.Duration
1350 def ToCoverageSummary(self) -> CoverageSummary:
1351 """
1352 Convert the format's model to the common model, and aggregate it.
1354 The packages of all groups are converted. A source file's path is its package's name and its name. A line's
1355 missed and covered branches become as many branches. A method spans its first line and the next lines of its file,
1356 as many as its ``LINE`` counter counts; a class spans its methods.
1358 :returns: The report's root of the common model, named after the report - or, if the report's
1359 name is empty, after the report file.
1360 :raises CodeCoverageError: If two packages of the same name state a source file of the same name.
1361 :raises CodeCoverageError: If a file's path runs through another file.
1362 """
1363 def iteratePackages(element: Report | Group) -> Generator[Package, None, None]:
1364 """
1365 Nested function for recursion.
1367 :param element: The report or a group.
1368 :returns: A generator of the packages of the element and of its groups.
1369 """
1370 for group in element._groups.values():
1371 yield from iteratePackages(group)
1373 yield from element._packages.values()
1375 def getStatus(element: CountersMixin, counterType: CounterType) -> LineCoverageStatus:
1376 """
1377 Nested function returning the coverage state of a class or method by its ``CLASS`` or ``METHOD`` counter.
1379 :param element: The class or method.
1380 :param counterType: The counter's kind.
1381 :returns: Covered, if the counter counts a covered item, otherwise uncovered; unknown, if the element
1382 has no such counter.
1383 """
1384 if (counter := element._counters.get(counterType)) is None: 1384 ↛ 1385line 1384 didn't jump to line 1385 because the condition on line 1384 was never true
1385 return LineCoverageStatus.Unknown
1387 return LineCoverageStatus.Covered if counter._coveredCount > 0 else LineCoverageStatus.Uncovered
1389 summary = CoverageSummary(self._name if self._name is not None and self._name != "" else self._path.stem)
1391 for package in iteratePackages(self):
1392 for sourceFile in package._sourceFiles.values():
1393 file = summary.GetOrAddFile(Path(package._name) / sourceFile._name)
1394 for number in sorted(sourceFile._lines):
1395 line = sourceFile._lines[number]
1396 missedBranchCount = line._missedBranchCount if line._missedBranchCount is not None else 0
1397 coveredBranchCount = line._coveredBranchCount if line._coveredBranchCount is not None else 0
1398 branches = [cc_Branch(LineCoverageStatus.Covered) for _ in range(coveredBranchCount)]
1399 branches.extend(cc_Branch(LineCoverageStatus.Uncovered) for _ in range(missedBranchCount))
1400 if line._coveredInstructionCount is None:
1401 status = LineCoverageStatus.Unknown
1402 elif line._coveredInstructionCount == 0:
1403 status = LineCoverageStatus.Uncovered
1404 elif missedBranchCount > 0:
1405 status = LineCoverageStatus.PartiallyCovered
1406 else:
1407 status = LineCoverageStatus.Covered
1408 cc_Line(number, status, None, branches, parent=file)
1410 for package in iteratePackages(self):
1411 parent: cc_Unit | CoverageSummary = summary
1412 for part in (part for part in package._name.split("/") if part != ""):
1413 parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent)
1415 for klass in package._classes.values():
1416 file = None
1417 if klass._sourceFileName is not None and klass._sourceFileName in package._sourceFiles:
1418 file = summary.GetOrAddFile(Path(package._name) / klass._sourceFileName)
1420 # per method with a first line in the file: its first and last line
1421 spans: dict[str, tuple[cc_Line, cc_Line]] = {}
1422 for key, method in klass._methods.items():
1423 if (
1424 file is None or method._lineNumber is None or method._lineNumber > file._lastLineNumber or
1425 (startLine := file._lines[method._lineNumber]) is None
1426 ):
1427 continue
1429 counter = method._counters.get(CounterType.Line)
1430 lineCount = counter._missedCount + counter._coveredCount if counter is not None else 1
1431 lines = list(islice(file.IterateLines(startLine), max(lineCount, 1)))
1432 spans[key] = (startLine, lines[-1])
1434 if (classUnit := parent._units.get(name := klass._name.rpartition("/")[2])) is None: 1434 ↛ 1444line 1434 didn't jump to line 1444 because the condition on line 1434 was always true
1435 classUnit = cc_Class(
1436 name,
1437 file=file,
1438 startLine=min((span[0] for span in spans.values()), key=lambda line: line._lineNumber, default=None),
1439 endLine=max((span[1] for span in spans.values()), key=lambda line: line._lineNumber, default=None),
1440 status=getStatus(klass, CounterType.Class),
1441 parent=parent
1442 )
1444 for key, method in klass._methods.items():
1445 if key not in classUnit._units: 1445 ↛ 1444line 1445 didn't jump to line 1444 because the condition on line 1445 was always true
1446 cc_Method(
1447 key,
1448 file=file,
1449 startLine=spans[key][0] if key in spans else None,
1450 endLine=spans[key][1] if key in spans else None,
1451 status=getStatus(method, CounterType.Method),
1452 parent=classUnit
1453 )
1455 summary.Aggregate()
1456 return summary