From 3cc0b5bf07ae3607f7601e9e629fee864b0ad6dc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=B7=AF=E4=BB=8E=E4=BB=8A=E5=A4=9C=E7=99=BD?=
<1174273035@qq.com>
Date: Fri, 23 Oct 2026 19:37:26 +0800
Subject: [PATCH] Preserve supplied XML attributes during lazy statistics reads
---
src/junitparser/junitparser.py | 16 +++-
tests/test_lazy_statistics.py | 153 +++++++++++++++++++++++++++++++++
2 files changed, 166 insertions(+), 3 deletions(-)
create mode 100644 tests/test_lazy_statistics.py
diff --git a/src/junitparser/junitparser.py b/src/junitparser/junitparser.py
index eaeae11..f82508d 100644
--- a/src/junitparser/junitparser.py
+++ b/src/junitparser/junitparser.py
@@ -11,7 +11,7 @@
import io
import itertools
-from copy import deepcopy
+from copy import copy, deepcopy
from pathlib import Path
from typing import List, Iterator, IO
@@ -97,6 +97,16 @@ def __set__(self, instance, value: str):
instance._elem.attrib[self.name] = str(value)
+def _update_missing_statistics(instance):
+ """Fill absent summary attributes without rewriting supplied XML values."""
+ calculated = copy(instance)
+ calculated._elem = deepcopy(instance._elem)
+ calculated.update_statistics()
+ for name, value in calculated._elem.attrib.items():
+ if name not in instance._elem.attrib:
+ instance._elem.attrib[name] = value
+
+
class IntAttr(Attr):
"""An integer attribute for an XML element.
@@ -107,7 +117,7 @@ class IntAttr(Attr):
def __get__(self, instance, cls):
result = super().__get__(instance, cls)
if result is None and isinstance(instance, (JUnitXml, TestSuite)):
- instance.update_statistics()
+ _update_missing_statistics(instance)
result = super().__get__(instance, cls)
return int(result) if result else None
@@ -127,7 +137,7 @@ class FloatAttr(Attr):
def __get__(self, instance, cls):
result = super().__get__(instance, cls)
if result is None and isinstance(instance, (JUnitXml, TestSuite)):
- instance.update_statistics()
+ _update_missing_statistics(instance)
result = super().__get__(instance, cls)
return float(result.replace(",", "")) if result else None
diff --git a/tests/test_lazy_statistics.py b/tests/test_lazy_statistics.py
new file mode 100644
index 0000000..24d4aea
--- /dev/null
+++ b/tests/test_lazy_statistics.py
@@ -0,0 +1,153 @@
+import io
+import threading
+
+import pytest
+
+from src.junitparser import JUnitXml, TestCase, TestSuite
+from src.junitparser import xunit2
+
+
+PARALLEL = (
+ ''
+ ''
+ ""
+)
+
+
+@pytest.mark.parametrize(
+ "attribute,value",
+ [("tests", 2), ("errors", 0), ("failures", 0), ("skipped", 0), ("time", 17)],
+)
+def test_missing_root_statistics_preserve_reported_time(attribute, value):
+ report = JUnitXml.fromstring(PARALLEL)
+ suite = next(iter(report))
+ assert getattr(report, attribute) == value
+ assert suite.time == 10
+ output = io.BytesIO()
+ report.write(output)
+ assert next(iter(JUnitXml.fromfile(io.BytesIO(output.getvalue())))).time == 10
+ report.update_statistics()
+ assert suite.time == 17
+
+
+@pytest.mark.parametrize(
+ "attribute,value", [("tests", 2), ("errors", 0), ("failures", 0), ("skipped", 0)]
+)
+def test_missing_suite_statistics_preserve_reported_time(attribute, value):
+ suite = next(iter(JUnitXml.fromstring(PARALLEL)))
+ assert getattr(suite, attribute) == value
+ assert suite.time == 10
+
+
+def test_missing_time_preserves_explicit_counts():
+ suite = TestSuite.fromstring(
+ ''
+ )
+ assert suite.time == 8
+ assert suite.tests == 42
+ suite.update_statistics()
+ assert suite.tests == 1
+
+
+def test_zero_is_not_missing_and_mutations_still_recompute():
+ suite = TestSuite.fromstring(
+ ''
+ )
+ assert suite.failures == 0
+ assert suite.tests == 0
+ assert suite.time == 0
+ suite.add_testcase(TestCase("extra", time=3))
+ assert suite.time == 11
+ assert suite.tests == 2
+
+
+def test_lazy_statistics_do_not_copy_non_xml_subclass_state():
+ class LockedSuite(TestSuite):
+ def __init__(self):
+ super().__init__()
+ self.lock = threading.Lock()
+
+ suite = LockedSuite.fromstring(
+ ''
+ )
+ assert suite.tests == 1
+ assert suite.time == 10
+
+
+def test_missing_standard_statistics_are_cached_together():
+ class CountingSuite(TestSuite):
+ calls = 0
+
+ def update_statistics(self):
+ type(self).calls += 1
+ super().update_statistics()
+
+ suite = CountingSuite.fromstring(
+ ''
+ )
+ assert (suite.tests, suite.errors, suite.failures, suite.skipped) == (1, 0, 0, 0)
+ assert CountingSuite.calls == 1
+ assert suite.time == 10
+
+
+def test_nested_xunit2_preserves_annotations():
+ report = xunit2.JUnitXml.fromstring(
+ ''
+ )
+ outer = next(iter(report))
+ inner = next(outer.testsuites())
+ assert report.tests == 2
+ assert report.time == 17
+ assert report.skipped is None
+ assert "skipped" not in report._elem.attrib
+ assert outer.time == 10
+ assert inner.time == 6
+
+
+@pytest.mark.parametrize("descriptor,value", [("IntAttr", 7), ("FloatAttr", 7.5)])
+def test_custom_computed_numeric_attribute(descriptor, value):
+ from src.junitparser import FloatAttr, IntAttr
+
+ descriptor_type = {"IntAttr": IntAttr, "FloatAttr": FloatAttr}[descriptor]
+
+ class CustomSuite(TestSuite):
+ custom = descriptor_type()
+ absent = descriptor_type()
+
+ def update_statistics(self):
+ super().update_statistics()
+ self.custom = value
+
+ suite = CustomSuite.fromstring(
+ ''
+ )
+ assert suite.custom == value
+ assert suite.absent is None
+ assert suite.time == 10
+
+
+def test_computed_custom_metadata_is_cached_with_statistics():
+ from src.junitparser import Attr
+
+ class CustomSuite(TestSuite):
+ label = Attr()
+ calls = 0
+
+ def update_statistics(self):
+ type(self).calls += 1
+ super().update_statistics()
+ self.label = "calculated"
+
+ suite = CustomSuite.fromstring(
+ ''
+ )
+ assert suite.tests == 1
+ assert suite.label == "calculated"
+ assert suite.errors == 0
+ assert CustomSuite.calls == 1
+ assert suite.time == 10
+ explicit = CustomSuite.fromstring(
+ ''
+ )
+ assert explicit.tests == 1
+ assert explicit.label == "supplied"