junitparser 3.2.0__tar.gz → 4.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. junitparser-4.0.0/MANIFEST.in +1 -0
  2. {junitparser-3.2.0 → junitparser-4.0.0}/PKG-INFO +25 -13
  3. {junitparser-3.2.0 → junitparser-4.0.0}/README.rst +14 -6
  4. junitparser-4.0.0/junitparser/__init__.py +38 -0
  5. {junitparser-3.2.0 → junitparser-4.0.0}/junitparser/cli.py +25 -33
  6. {junitparser-3.2.0 → junitparser-4.0.0}/junitparser/junitparser.py +102 -77
  7. {junitparser-3.2.0 → junitparser-4.0.0}/junitparser/xunit2.py +123 -96
  8. {junitparser-3.2.0 → junitparser-4.0.0}/junitparser.egg-info/PKG-INFO +25 -13
  9. {junitparser-3.2.0 → junitparser-4.0.0}/junitparser.egg-info/SOURCES.txt +9 -4
  10. junitparser-4.0.0/pyproject.toml +38 -0
  11. {junitparser-3.2.0 → junitparser-4.0.0}/setup.cfg +0 -3
  12. junitparser-4.0.0/tests/data/jenkins.xml +23 -0
  13. junitparser-4.0.0/tests/data/no_fails.xml +16 -0
  14. junitparser-4.0.0/tests/data/no_suites_tag.xml +15 -0
  15. junitparser-4.0.0/tests/data/normal.xml +18 -0
  16. junitparser-4.0.0/tests/data/pytest_error.xml +8 -0
  17. junitparser-4.0.0/tests/data/pytest_success.xml +6 -0
  18. junitparser-4.0.0/tests/test_cli.py +107 -0
  19. junitparser-4.0.0/tests/test_fromfile.py +203 -0
  20. {junitparser-3.2.0 → junitparser-4.0.0}/tests/test_general.py +42 -13
  21. junitparser-4.0.0/tests/test_write.py +284 -0
  22. junitparser-4.0.0/tests/test_xunit2.py +240 -0
  23. junitparser-3.2.0/junitparser/__init__.py +0 -17
  24. junitparser-3.2.0/junitparser.egg-info/not-zip-safe +0 -1
  25. junitparser-3.2.0/pyproject.toml +0 -3
  26. junitparser-3.2.0/setup.py +0 -36
  27. junitparser-3.2.0/tests/test_cli.py +0 -14
  28. junitparser-3.2.0/tests/test_fromfile.py +0 -223
  29. junitparser-3.2.0/tests/test_xunit2.py +0 -114
  30. {junitparser-3.2.0 → junitparser-4.0.0}/LICENSE +0 -0
  31. {junitparser-3.2.0 → junitparser-4.0.0}/junitparser/__main__.py +0 -0
  32. {junitparser-3.2.0 → junitparser-4.0.0}/junitparser/py.typed +0 -0
  33. {junitparser-3.2.0 → junitparser-4.0.0}/junitparser.egg-info/dependency_links.txt +0 -0
  34. {junitparser-3.2.0 → junitparser-4.0.0}/junitparser.egg-info/entry_points.txt +0 -0
  35. {junitparser-3.2.0 → junitparser-4.0.0}/junitparser.egg-info/top_level.txt +0 -0
@@ -0,0 +1 @@
1
+ include tests/data/*.xml
@@ -1,23 +1,27 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: junitparser
3
- Version: 3.2.0
3
+ Version: 4.0.0
4
4
  Summary: Manipulates JUnit/xUnit Result XML files
5
- Home-page: https://github.com/weiwei/junitparser
6
- Author: Weiwei Wang
7
- Author-email: gastlygem@gmail.com
8
- License: Apache 2.0
9
- Keywords: junit xunit xml parser
5
+ Author-email: Weiwei Wang <gastlygem@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/weiwei/junitparser
8
+ Project-URL: Issues, https://github.com/weiwei/junitparser/issues
9
+ Project-URL: Documentation, https://junitparser.readthedocs.io/
10
+ Keywords: junit,xunit,xml,parser
10
11
  Classifier: Development Status :: 5 - Production/Stable
11
12
  Classifier: Intended Audience :: Developers
12
13
  Classifier: License :: OSI Approved :: Apache Software License
13
14
  Classifier: Topic :: Text Processing
14
15
  Classifier: Programming Language :: Python :: 3
16
+ Requires-Python: >=3.6
17
+ Description-Content-Type: text/x-rst
15
18
  License-File: LICENSE
19
+ Dynamic: license-file
16
20
 
17
21
  junitparser -- Pythonic JUnit/xUnit Result XML Parser
18
22
  ======================================================
19
23
 
20
- .. image:: https://github.com/weiwei/junitparser/workflows/build/badge.svg?branch=master
24
+ .. image:: https://github.com/weiwei/junitparser/actions/workflows/ci.yml/badge.svg?branch=master
21
25
  :target: https://github.com/weiwei/junitparser/actions
22
26
  .. image:: https://codecov.io/gh/weiwei/junitparser/branch/master/graph/badge.svg?token=UotlfRXNnK
23
27
  :target: https://codecov.io/gh/weiwei/junitparser
@@ -157,8 +161,7 @@ Junitparser also support extra schemas:
157
161
 
158
162
  .. code-block:: python
159
163
 
160
- from junitparser.xunit2 import JUnitParser, TestCase, TestSuite, \
161
- RerunFailure
164
+ from junitparser.xunit2 import TestCase, TestSuite, RerunFailure
162
165
  # These classes are redefined to support extra properties and attributes
163
166
  # of the xunit2 schema.
164
167
  suite = TestSuite("mySuite")
@@ -168,7 +171,7 @@ Junitparser also support extra schemas:
168
171
  rerun_failure.stack_trace = "Stack"
169
172
  rerun_failure.system_err = "E404"
170
173
  rerun_failure.system_out = "NOT FOUND"
171
- case.add_rerun_result(rerun_failure)
174
+ case.add_interim_result(rerun_failure)
172
175
 
173
176
  Currently supported schemas including:
174
177
 
@@ -295,9 +298,18 @@ Command Line
295
298
  Test
296
299
  ----
297
300
 
298
- The tests are written with python ``unittest``, to run them, use pytest::
301
+ The tests are written with python ``unittest``, to run them, use
302
+ `pytest <https://pypi.org/project/pytest/>`_::
299
303
 
300
- pytest test.py
304
+ pytest
305
+
306
+ If you get a failure like ``unsupported locale setting`` you may need to add
307
+ extra locales that the tests use. Refer to the steps used in the
308
+ `CI build workflow <.github/workflows/build.yml>`_::
309
+
310
+ sudo locale-gen en_US.UTF-8
311
+ sudo locale-gen de_DE.UTF-8
312
+ sudo update-locale
301
313
 
302
314
  Contribute
303
315
  ----------
@@ -1,7 +1,7 @@
1
1
  junitparser -- Pythonic JUnit/xUnit Result XML Parser
2
2
  ======================================================
3
3
 
4
- .. image:: https://github.com/weiwei/junitparser/workflows/build/badge.svg?branch=master
4
+ .. image:: https://github.com/weiwei/junitparser/actions/workflows/ci.yml/badge.svg?branch=master
5
5
  :target: https://github.com/weiwei/junitparser/actions
6
6
  .. image:: https://codecov.io/gh/weiwei/junitparser/branch/master/graph/badge.svg?token=UotlfRXNnK
7
7
  :target: https://codecov.io/gh/weiwei/junitparser
@@ -141,8 +141,7 @@ Junitparser also support extra schemas:
141
141
 
142
142
  .. code-block:: python
143
143
 
144
- from junitparser.xunit2 import JUnitParser, TestCase, TestSuite, \
145
- RerunFailure
144
+ from junitparser.xunit2 import TestCase, TestSuite, RerunFailure
146
145
  # These classes are redefined to support extra properties and attributes
147
146
  # of the xunit2 schema.
148
147
  suite = TestSuite("mySuite")
@@ -152,7 +151,7 @@ Junitparser also support extra schemas:
152
151
  rerun_failure.stack_trace = "Stack"
153
152
  rerun_failure.system_err = "E404"
154
153
  rerun_failure.system_out = "NOT FOUND"
155
- case.add_rerun_result(rerun_failure)
154
+ case.add_interim_result(rerun_failure)
156
155
 
157
156
  Currently supported schemas including:
158
157
 
@@ -279,9 +278,18 @@ Command Line
279
278
  Test
280
279
  ----
281
280
 
282
- The tests are written with python ``unittest``, to run them, use pytest::
281
+ The tests are written with python ``unittest``, to run them, use
282
+ `pytest <https://pypi.org/project/pytest/>`_::
283
283
 
284
- pytest test.py
284
+ pytest
285
+
286
+ If you get a failure like ``unsupported locale setting`` you may need to add
287
+ extra locales that the tests use. Refer to the steps used in the
288
+ `CI build workflow <.github/workflows/build.yml>`_::
289
+
290
+ sudo locale-gen en_US.UTF-8
291
+ sudo locale-gen de_DE.UTF-8
292
+ sudo update-locale
285
293
 
286
294
  Contribute
287
295
  ----------
@@ -0,0 +1,38 @@
1
+ from .junitparser import (
2
+ Attr,
3
+ Element,
4
+ Error,
5
+ Failure,
6
+ FloatAttr,
7
+ IntAttr,
8
+ JUnitXml,
9
+ JUnitXmlError,
10
+ Properties,
11
+ Property,
12
+ Skipped,
13
+ SystemOut,
14
+ SystemErr,
15
+ TestCase,
16
+ TestSuite,
17
+ )
18
+
19
+ version = "4.0.0"
20
+
21
+ __all__ = [
22
+ "Attr",
23
+ "Element",
24
+ "Error",
25
+ "Failure",
26
+ "FloatAttr",
27
+ "IntAttr",
28
+ "JUnitXml",
29
+ "JUnitXmlError",
30
+ "Properties",
31
+ "Property",
32
+ "Skipped",
33
+ "SystemOut",
34
+ "SystemErr",
35
+ "TestCase",
36
+ "TestSuite",
37
+ "version"
38
+ ]
@@ -1,3 +1,4 @@
1
+ import sys
1
2
  from argparse import ArgumentParser
2
3
  from glob import iglob
3
4
  from itertools import chain
@@ -5,8 +6,8 @@ from itertools import chain
5
6
  from . import JUnitXml, version
6
7
 
7
8
 
8
- def merge(paths, output, suite_name):
9
- """Merge XML report."""
9
+ def merge(paths, output, suite_name=""):
10
+ """Merge XML reports."""
10
11
  result = JUnitXml()
11
12
  for path in paths:
12
13
  result += JUnitXml.fromfile(path)
@@ -14,7 +15,7 @@ def merge(paths, output, suite_name):
14
15
  result.update_statistics()
15
16
  if suite_name:
16
17
  result.name = suite_name
17
- result.write(output, to_console=output == "-")
18
+ result.write(sys.stdout if output == "-" else output)
18
19
  return 0
19
20
 
20
21
 
@@ -43,18 +44,23 @@ def _parser(prog_name=None): # pragma: no cover
43
44
  command_parser = parser.add_subparsers(dest="command", help="command")
44
45
  command_parser.required = True
45
46
 
46
- # command: merge
47
- merge_parser = command_parser.add_parser(
48
- "merge", help="Merge JUnit XML format reports with junitparser."
49
- )
50
- merge_parser.add_argument(
47
+ # an abstract object that defines common arguments used by multiple commands
48
+ abstract_parser = ArgumentParser(add_help=False)
49
+ abstract_parser.add_argument(
51
50
  "--glob",
52
51
  help="Treat original XML path(s) as glob(s).",
53
52
  dest="paths_are_globs",
54
53
  action="store_true",
55
54
  default=False,
56
55
  )
57
- merge_parser.add_argument("paths", nargs="+", help="Original XML path(s).")
56
+ abstract_parser.add_argument("paths", help="Original XML path(s).", nargs="+")
57
+
58
+ # command: merge
59
+ merge_parser = command_parser.add_parser(
60
+ "merge",
61
+ help="Merge JUnit XML format reports with junitparser.",
62
+ parents=[abstract_parser],
63
+ )
58
64
  merge_parser.add_argument(
59
65
  "output", help='Merged XML Path, setting to "-" will output to the console'
60
66
  )
@@ -64,19 +70,10 @@ def _parser(prog_name=None): # pragma: no cover
64
70
  )
65
71
 
66
72
  # command: verify
67
- merge_parser = command_parser.add_parser(
73
+ verify_parser = command_parser.add_parser( # noqa: F841
68
74
  "verify",
69
75
  help="Return a non-zero exit code if one of the testcases failed or errored.",
70
- )
71
- merge_parser.add_argument(
72
- "--glob",
73
- help="Treat original XML path(s) as glob(s).",
74
- dest="paths_are_globs",
75
- action="store_true",
76
- default=False,
77
- )
78
- merge_parser.add_argument(
79
- "paths", nargs="+", help="XML path(s) of reports to verify."
76
+ parents=[abstract_parser],
80
77
  )
81
78
 
82
79
  return parser
@@ -84,19 +81,14 @@ def _parser(prog_name=None): # pragma: no cover
84
81
 
85
82
  def main(args=None, prog_name=None):
86
83
  """CLI's main runner."""
87
- args = args or _parser(prog_name=prog_name).parse_args()
84
+ args = _parser(prog_name=prog_name).parse_args(args)
85
+ paths = (
86
+ chain.from_iterable(iglob(path) for path in args.paths)
87
+ if args.paths_are_globs
88
+ else args.paths
89
+ )
88
90
  if args.command == "merge":
89
- return merge(
90
- chain.from_iterable(iglob(path) for path in args.paths)
91
- if args.paths_are_globs
92
- else args.paths,
93
- args.output,
94
- args.suite_name,
95
- )
91
+ return merge(paths, args.output, args.suite_name)
96
92
  if args.command == "verify":
97
- return verify(
98
- chain.from_iterable(iglob(path) for path in args.paths)
99
- if args.paths_are_globs
100
- else args.paths
101
- )
93
+ return verify(paths)
102
94
  return 255
@@ -8,9 +8,10 @@ This, according to the document, is Apache Ant's JUnit output.
8
8
 
9
9
  See the documentation for other supported schemas.
10
10
  """
11
+ import io
11
12
  import itertools
12
13
  from copy import deepcopy
13
- from typing import List, Union
14
+ from typing import List, Union, Iterator, IO, Optional
14
15
 
15
16
  try:
16
17
  from lxml import etree
@@ -18,12 +19,12 @@ except ImportError:
18
19
  from xml.etree import ElementTree as etree
19
20
 
20
21
 
21
- def write_xml(obj, filepath=None, pretty=False, to_console=False):
22
+ def write_xml(obj, file_or_filename: Optional[Union[str, IO]] = None, *, pretty: bool = False):
22
23
  tree = etree.ElementTree(obj._elem)
23
- if filepath is None:
24
- filepath = obj.filepath
25
- if filepath is None:
26
- raise JUnitXmlError("Missing filepath argument.")
24
+ if file_or_filename is None:
25
+ file_or_filename = obj.filepath
26
+ if file_or_filename is None:
27
+ raise JUnitXmlError("Missing file argument.")
27
28
 
28
29
  if pretty:
29
30
  from xml.dom.minidom import parseString
@@ -31,20 +32,23 @@ def write_xml(obj, filepath=None, pretty=False, to_console=False):
31
32
  text = etree.tostring(obj._elem)
32
33
  xml = parseString(text) # nosec
33
34
  content = xml.toprettyxml(encoding="utf-8")
34
- if to_console:
35
- print(content)
36
- else:
37
- with open(filepath, "wb") as xmlfile:
35
+ if isinstance(file_or_filename, str):
36
+ with open(file_or_filename, encoding="utf-8", mode="wb") as xmlfile:
38
37
  xmlfile.write(content)
38
+ else:
39
+ if isinstance(file_or_filename, io.TextIOWrapper):
40
+ if file_or_filename.encoding is not None and file_or_filename.encoding.lower() != "utf-8":
41
+ raise ValueError(f"Only utf-8 encoding is supported: {file_or_filename.encoding}")
42
+ file_or_filename.buffer.write(content)
43
+ else:
44
+ file_or_filename.write(content)
39
45
  else:
40
- if to_console:
41
- print(
42
- etree.tostring(
43
- obj._elem, encoding="utf-8", xml_declaration=True
44
- ).decode("utf-8")
45
- )
46
+ if isinstance(file_or_filename, io.TextIOWrapper):
47
+ if file_or_filename.encoding is not None and file_or_filename.encoding.lower() != "utf-8":
48
+ raise ValueError(f"Only utf-8 encoding is supported: {file_or_filename.encoding}")
49
+ tree.write(file_or_filename.buffer, encoding="utf-8", xml_declaration=True)
46
50
  else:
47
- tree.write(filepath, encoding="utf-8", xml_declaration=True)
51
+ tree.write(file_or_filename, encoding="utf-8", xml_declaration=True)
48
52
 
49
53
 
50
54
  class JUnitXmlError(Exception):
@@ -241,7 +245,13 @@ class Result(Element):
241
245
  self._elem.text = value
242
246
 
243
247
 
244
- class Skipped(Result):
248
+ class FinalResult(Result):
249
+ """Base class for final test result (in contrast to XUnit2 InterimResult)."""
250
+
251
+ _tag = None
252
+
253
+
254
+ class Skipped(FinalResult):
245
255
  """Test result when the case is skipped."""
246
256
 
247
257
  _tag = "skipped"
@@ -250,7 +260,7 @@ class Skipped(Result):
250
260
  return super().__eq__(other)
251
261
 
252
262
 
253
- class Failure(Result):
263
+ class Failure(FinalResult):
254
264
  """Test result when the case failed."""
255
265
 
256
266
  _tag = "failure"
@@ -259,7 +269,7 @@ class Failure(Result):
259
269
  return super().__eq__(other)
260
270
 
261
271
 
262
- class Error(Result):
272
+ class Error(FinalResult):
263
273
  """Test result when the case has errors during execution."""
264
274
 
265
275
  _tag = "error"
@@ -268,9 +278,6 @@ class Error(Result):
268
278
  return super().__eq__(other)
269
279
 
270
280
 
271
- POSSIBLE_RESULTS = {Failure, Error, Skipped}
272
-
273
-
274
281
  class System(Element):
275
282
  """Parent class for :class:`SystemOut` and :class:`SystemErr`.
276
283
 
@@ -316,6 +323,9 @@ class TestCase(Element):
316
323
  time = FloatAttr()
317
324
  __test__ = False
318
325
 
326
+ # JUnit TestCase children are final results, SystemOut and SystemErr
327
+ ITER_TYPES = {t._tag: t for t in (Failure, Error, Skipped, SystemOut, SystemErr)}
328
+
319
329
  def __init__(self, name: str = None, classname: str = None, time: float = None):
320
330
  super().__init__(self._tag)
321
331
  if name is not None:
@@ -328,12 +338,10 @@ class TestCase(Element):
328
338
  def __hash__(self):
329
339
  return super().__hash__()
330
340
 
331
- def __iter__(self):
332
- all_types = set.union(POSSIBLE_RESULTS, {SystemOut}, {SystemErr})
341
+ def __iter__(self) -> Iterator[Union[Result, System]]:
333
342
  for elem in self._elem.iter():
334
- for entry_type in all_types:
335
- if elem.tag == entry_type._tag:
336
- yield entry_type.fromelem(elem)
343
+ if elem.tag in self.ITER_TYPES:
344
+ yield self.ITER_TYPES[elem.tag].fromelem(elem)
337
345
 
338
346
  def __eq__(self, other):
339
347
  # TODO: May not work correctly if unreliable hash method is used.
@@ -344,36 +352,41 @@ class TestCase(Element):
344
352
  """Whether this testcase was a success (i.e. if it isn't skipped, failed, or errored)."""
345
353
  return not self.result
346
354
 
355
+ @property
356
+ def is_failure(self):
357
+ """Whether this testcase failed."""
358
+ return any(isinstance(r, Failure) for r in self.result)
359
+
360
+ @property
361
+ def is_error(self):
362
+ """Whether this testcase errored."""
363
+ return any(isinstance(r, Error) for r in self.result)
364
+
347
365
  @property
348
366
  def is_skipped(self):
349
367
  """Whether this testcase was skipped."""
350
- for r in self.result:
351
- if isinstance(r, Skipped):
352
- return True
353
- return False
368
+ return any(isinstance(r, Skipped) for r in self.result)
354
369
 
355
370
  @property
356
- def result(self):
371
+ def result(self) -> List[FinalResult]:
357
372
  """A list of :class:`Failure`, :class:`Skipped`, or :class:`Error` objects."""
358
- results = []
359
- for entry in self:
360
- if isinstance(entry, tuple(POSSIBLE_RESULTS)):
361
- results.append(entry)
362
-
363
- return results
373
+ return [entry for entry in self if isinstance(entry, FinalResult)]
364
374
 
365
375
  @result.setter
366
- def result(self, value: Union[Result, List[Result]]):
376
+ def result(self, value: Union[FinalResult, List[FinalResult]]):
377
+ # Check typing
378
+ if not (isinstance(value, FinalResult) or
379
+ isinstance(value, list) and all(isinstance(item, FinalResult) for item in value)):
380
+ raise ValueError("Value must be either FinalResult or list of FinalResult")
381
+
367
382
  # First remove all existing results
368
383
  for entry in self.result:
369
- if any(isinstance(entry, r) for r in POSSIBLE_RESULTS):
370
- self.remove(entry)
371
- if isinstance(value, Result):
384
+ self.remove(entry)
385
+ if isinstance(value, FinalResult):
372
386
  self.append(value)
373
- elif isinstance(value, list):
387
+ else:
374
388
  for entry in value:
375
- if any(isinstance(entry, r) for r in POSSIBLE_RESULTS):
376
- self.append(entry)
389
+ self.append(entry)
377
390
 
378
391
  @property
379
392
  def system_out(self):
@@ -454,7 +467,7 @@ class Properties(Element):
454
467
  def add_property(self, property_: Property):
455
468
  self.append(property_)
456
469
 
457
- def __iter__(self):
470
+ def __iter__(self) -> Iterator[Property]:
458
471
  return super().iterchildren(Property)
459
472
 
460
473
  def __eq__(self, other):
@@ -495,15 +508,18 @@ class TestSuite(Element):
495
508
  skipped = IntAttr()
496
509
  __test__ = False
497
510
 
511
+ testcase = TestCase
512
+
498
513
  def __init__(self, name=None):
499
514
  super().__init__(self._tag)
500
515
  self.name = name
501
516
  self.filepath = None
517
+ self.root = JUnitXml
502
518
 
503
- def __iter__(self):
519
+ def __iter__(self) -> Iterator[TestCase]:
504
520
  return itertools.chain(
505
- super().iterchildren(TestCase),
506
- (case for suite in super().iterchildren(TestSuite) for case in suite),
521
+ super().iterchildren(self.testcase),
522
+ (case for suite in super().iterchildren(type(self)) for case in suite),
507
523
  )
508
524
 
509
525
  def __len__(self):
@@ -537,7 +553,7 @@ class TestSuite(Element):
537
553
  result.update_statistics()
538
554
  else:
539
555
  # Create a new test result containing two testsuites
540
- result = JUnitXml()
556
+ result = self.root()
541
557
  result.add_testsuite(self)
542
558
  result.add_testsuite(other)
543
559
  return result
@@ -551,7 +567,7 @@ class TestSuite(Element):
551
567
  self.update_statistics()
552
568
  return self
553
569
 
554
- result = JUnitXml()
570
+ result = self.root()
555
571
  result.filepath = self.filepath
556
572
  result.add_testsuite(self)
557
573
  result.add_testsuite(other)
@@ -638,11 +654,10 @@ class TestSuite(Element):
638
654
 
639
655
  def testsuites(self):
640
656
  """Iterate through all testsuites."""
641
- for suite in self.iterchildren(TestSuite):
642
- yield suite
657
+ yield from self.iterchildren(type(self))
643
658
 
644
- def write(self, filepath: str = None, pretty=False):
645
- write_xml(self, filepath=filepath, pretty=pretty)
659
+ def write(self, file_or_filename: Optional[Union[str, IO]] = None, *, pretty: bool = False):
660
+ write_xml(self, file_or_filename=file_or_filename, pretty=pretty)
646
661
 
647
662
 
648
663
  class JUnitXml(Element):
@@ -674,14 +689,14 @@ class JUnitXml(Element):
674
689
  self.filepath = None
675
690
  self.name = name
676
691
 
677
- def __iter__(self):
678
- return super().iterchildren(TestSuite)
692
+ def __iter__(self) -> Iterator[TestSuite]:
693
+ return super().iterchildren(self.testsuite)
679
694
 
680
695
  def __len__(self):
681
696
  return len(list(self.__iter__()))
682
697
 
683
698
  def __add__(self, other):
684
- result = JUnitXml()
699
+ result = type(self)()
685
700
  for suite in self:
686
701
  result.add_testsuite(suite)
687
702
  for suite in other:
@@ -693,7 +708,7 @@ class JUnitXml(Element):
693
708
  for suite in other:
694
709
  self.add_testsuite(suite)
695
710
  elif other._elem.tag == "testsuite":
696
- suite = TestSuite(name=other.name)
711
+ suite = self.testsuite(name=other.name)
697
712
  for case in other:
698
713
  suite._add_testcase_no_update_stats(case)
699
714
  self.add_testsuite(suite)
@@ -728,39 +743,49 @@ class JUnitXml(Element):
728
743
  self.time = round(time, 3)
729
744
 
730
745
  @classmethod
731
- def fromroot(cls, root_elem: Element):
746
+ def fromroot(cls, root_elem: Element) -> "JUnitXml":
732
747
  """Construct JUnit objects from an elementTree root element."""
733
- if root_elem.tag == "testsuites":
734
- instance = cls()
735
- elif root_elem.tag == "testsuite":
736
- instance = cls.testsuite()
737
- else:
748
+ instance = cls()
749
+ if root_elem.tag == "testsuite":
750
+ testsuite_element = root_elem
751
+ root_elem = testsuite_element.makeelement("testsuites", {})
752
+ root_elem.append(testsuite_element)
753
+ if not root_elem.tag == "testsuites":
738
754
  raise JUnitXmlError("Invalid format.")
739
755
  instance._elem = root_elem
740
756
  return instance
741
757
 
742
758
  @classmethod
743
- def fromstring(cls, text: str):
744
- """Construct JUnit objects from an XML string."""
759
+ def fromstring(cls, text: Union[str, bytes]) -> "JUnitXml":
760
+ """Construct JUnit objects from an XML string (str or bytes)."""
745
761
  root_elem = etree.fromstring(text) # nosec
746
762
  return cls.fromroot(root_elem)
747
763
 
748
764
  @classmethod
749
- def fromfile(cls, filepath: str, parse_func=None):
750
- """Initiate the object from a report file."""
751
- if parse_func:
752
- tree = parse_func(filepath)
765
+ def fromfile(cls, file: Union[str, IO], parse_func=None) -> "JUnitXml":
766
+ """
767
+ Construct JUnit objects from an XML file.
768
+
769
+ The ``file`` can be any of the following:
770
+
771
+ - a file name/path
772
+ - a file object
773
+ - a file-like object
774
+ - a URL using the HTTP or FTP protocol (with lxml only)
775
+ """
776
+ if parse_func is not None:
777
+ tree = parse_func(file)
753
778
  else:
754
- tree = etree.parse(filepath) # nosec
779
+ tree = etree.parse(file) # nosec
755
780
  root_elem = tree.getroot()
756
781
  instance = cls.fromroot(root_elem)
757
- instance.filepath = filepath
782
+ instance.filepath = file if isinstance(file, str) else None
758
783
  return instance
759
784
 
760
- def write(self, filepath: str = None, pretty=False, to_console=False):
785
+ def write(self, file_or_filename: Optional[Union[str, IO]] = None, *, pretty: bool = False):
761
786
  """Write the object into a JUnit XML file.
762
787
 
763
- If `file_path` is not specified, it will write to the original file.
788
+ If `file_or_filename` is not specified, it will write to the original filename.
764
789
  If `pretty` is True, the result file will be more human friendly.
765
790
  """
766
- write_xml(self, filepath=filepath, pretty=pretty, to_console=to_console)
791
+ write_xml(self, file_or_filename=file_or_filename, pretty=pretty)