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"