junitparser 3.1.2__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.1.2 → junitparser-4.0.0}/PKG-INFO +25 -13
  3. {junitparser-3.1.2 → junitparser-4.0.0}/README.rst +14 -6
  4. junitparser-4.0.0/junitparser/__init__.py +38 -0
  5. {junitparser-3.1.2 → junitparser-4.0.0}/junitparser/cli.py +25 -33
  6. {junitparser-3.1.2 → junitparser-4.0.0}/junitparser/junitparser.py +105 -76
  7. {junitparser-3.1.2 → junitparser-4.0.0}/junitparser/xunit2.py +125 -96
  8. {junitparser-3.1.2 → junitparser-4.0.0}/junitparser.egg-info/PKG-INFO +25 -13
  9. {junitparser-3.1.2 → junitparser-4.0.0}/junitparser.egg-info/SOURCES.txt +9 -4
  10. junitparser-4.0.0/pyproject.toml +38 -0
  11. {junitparser-3.1.2 → 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.1.2 → junitparser-4.0.0}/tests/test_general.py +44 -14
  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.1.2/junitparser/__init__.py +0 -17
  24. junitparser-3.1.2/junitparser.egg-info/not-zip-safe +0 -1
  25. junitparser-3.1.2/pyproject.toml +0 -3
  26. junitparser-3.1.2/setup.py +0 -36
  27. junitparser-3.1.2/tests/test_cli.py +0 -14
  28. junitparser-3.1.2/tests/test_fromfile.py +0 -223
  29. junitparser-3.1.2/tests/test_xunit2.py +0 -103
  30. {junitparser-3.1.2 → junitparser-4.0.0}/LICENSE +0 -0
  31. {junitparser-3.1.2 → junitparser-4.0.0}/junitparser/__main__.py +0 -0
  32. {junitparser-3.1.2 → junitparser-4.0.0}/junitparser/py.typed +0 -0
  33. {junitparser-3.1.2 → junitparser-4.0.0}/junitparser.egg-info/dependency_links.txt +0 -0
  34. {junitparser-3.1.2 → junitparser-4.0.0}/junitparser.egg-info/entry_points.txt +0 -0
  35. {junitparser-3.1.2 → 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.1.2
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,10 +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
-
11
+ import io
12
12
  import itertools
13
13
  from copy import deepcopy
14
- from typing import List
14
+ from typing import List, Union, Iterator, IO, Optional
15
15
 
16
16
  try:
17
17
  from lxml import etree
@@ -19,12 +19,12 @@ except ImportError:
19
19
  from xml.etree import ElementTree as etree
20
20
 
21
21
 
22
- 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):
23
23
  tree = etree.ElementTree(obj._elem)
24
- if filepath is None:
25
- filepath = obj.filepath
26
- if filepath is None:
27
- 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.")
28
28
 
29
29
  if pretty:
30
30
  from xml.dom.minidom import parseString
@@ -32,20 +32,23 @@ def write_xml(obj, filepath=None, pretty=False, to_console=False):
32
32
  text = etree.tostring(obj._elem)
33
33
  xml = parseString(text) # nosec
34
34
  content = xml.toprettyxml(encoding="utf-8")
35
- if to_console:
36
- print(content)
37
- else:
38
- 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:
39
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)
40
45
  else:
41
- if to_console:
42
- print(
43
- etree.tostring(
44
- obj._elem, encoding="utf-8", xml_declaration=True
45
- ).decode("utf-8")
46
- )
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)
47
50
  else:
48
- tree.write(filepath, encoding="utf-8", xml_declaration=True)
51
+ tree.write(file_or_filename, encoding="utf-8", xml_declaration=True)
49
52
 
50
53
 
51
54
  class JUnitXmlError(Exception):
@@ -242,7 +245,13 @@ class Result(Element):
242
245
  self._elem.text = value
243
246
 
244
247
 
245
- 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):
246
255
  """Test result when the case is skipped."""
247
256
 
248
257
  _tag = "skipped"
@@ -251,7 +260,7 @@ class Skipped(Result):
251
260
  return super().__eq__(other)
252
261
 
253
262
 
254
- class Failure(Result):
263
+ class Failure(FinalResult):
255
264
  """Test result when the case failed."""
256
265
 
257
266
  _tag = "failure"
@@ -260,7 +269,7 @@ class Failure(Result):
260
269
  return super().__eq__(other)
261
270
 
262
271
 
263
- class Error(Result):
272
+ class Error(FinalResult):
264
273
  """Test result when the case has errors during execution."""
265
274
 
266
275
  _tag = "error"
@@ -269,9 +278,6 @@ class Error(Result):
269
278
  return super().__eq__(other)
270
279
 
271
280
 
272
- POSSIBLE_RESULTS = {Failure, Error, Skipped}
273
-
274
-
275
281
  class System(Element):
276
282
  """Parent class for :class:`SystemOut` and :class:`SystemErr`.
277
283
 
@@ -317,6 +323,9 @@ class TestCase(Element):
317
323
  time = FloatAttr()
318
324
  __test__ = False
319
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
+
320
329
  def __init__(self, name: str = None, classname: str = None, time: float = None):
321
330
  super().__init__(self._tag)
322
331
  if name is not None:
@@ -329,12 +338,10 @@ class TestCase(Element):
329
338
  def __hash__(self):
330
339
  return super().__hash__()
331
340
 
332
- def __iter__(self):
333
- all_types = set.union(POSSIBLE_RESULTS, {SystemOut}, {SystemErr})
341
+ def __iter__(self) -> Iterator[Union[Result, System]]:
334
342
  for elem in self._elem.iter():
335
- for entry_type in all_types:
336
- if elem.tag == entry_type._tag:
337
- yield entry_type.fromelem(elem)
343
+ if elem.tag in self.ITER_TYPES:
344
+ yield self.ITER_TYPES[elem.tag].fromelem(elem)
338
345
 
339
346
  def __eq__(self, other):
340
347
  # TODO: May not work correctly if unreliable hash method is used.
@@ -345,32 +352,40 @@ class TestCase(Element):
345
352
  """Whether this testcase was a success (i.e. if it isn't skipped, failed, or errored)."""
346
353
  return not self.result
347
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
+
348
365
  @property
349
366
  def is_skipped(self):
350
367
  """Whether this testcase was skipped."""
351
- for r in self.result:
352
- if isinstance(r, Skipped):
353
- return True
354
- return False
368
+ return any(isinstance(r, Skipped) for r in self.result)
355
369
 
356
370
  @property
357
- def result(self):
371
+ def result(self) -> List[FinalResult]:
358
372
  """A list of :class:`Failure`, :class:`Skipped`, or :class:`Error` objects."""
359
- results = []
360
- for entry in self:
361
- if isinstance(entry, tuple(POSSIBLE_RESULTS)):
362
- results.append(entry)
363
-
364
- return results
373
+ return [entry for entry in self if isinstance(entry, FinalResult)]
365
374
 
366
375
  @result.setter
367
- def result(self, value: 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
+
368
382
  # First remove all existing results
369
383
  for entry in self.result:
370
- if any(isinstance(entry, r) for r in POSSIBLE_RESULTS):
371
- self.remove(entry)
372
- for entry in value:
373
- if any(isinstance(entry, r) for r in POSSIBLE_RESULTS):
384
+ self.remove(entry)
385
+ if isinstance(value, FinalResult):
386
+ self.append(value)
387
+ else:
388
+ for entry in value:
374
389
  self.append(entry)
375
390
 
376
391
  @property
@@ -452,7 +467,7 @@ class Properties(Element):
452
467
  def add_property(self, property_: Property):
453
468
  self.append(property_)
454
469
 
455
- def __iter__(self):
470
+ def __iter__(self) -> Iterator[Property]:
456
471
  return super().iterchildren(Property)
457
472
 
458
473
  def __eq__(self, other):
@@ -493,15 +508,18 @@ class TestSuite(Element):
493
508
  skipped = IntAttr()
494
509
  __test__ = False
495
510
 
511
+ testcase = TestCase
512
+
496
513
  def __init__(self, name=None):
497
514
  super().__init__(self._tag)
498
515
  self.name = name
499
516
  self.filepath = None
517
+ self.root = JUnitXml
500
518
 
501
- def __iter__(self):
519
+ def __iter__(self) -> Iterator[TestCase]:
502
520
  return itertools.chain(
503
- super().iterchildren(TestCase),
504
- (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),
505
523
  )
506
524
 
507
525
  def __len__(self):
@@ -535,7 +553,7 @@ class TestSuite(Element):
535
553
  result.update_statistics()
536
554
  else:
537
555
  # Create a new test result containing two testsuites
538
- result = JUnitXml()
556
+ result = self.root()
539
557
  result.add_testsuite(self)
540
558
  result.add_testsuite(other)
541
559
  return result
@@ -549,7 +567,7 @@ class TestSuite(Element):
549
567
  self.update_statistics()
550
568
  return self
551
569
 
552
- result = JUnitXml()
570
+ result = self.root()
553
571
  result.filepath = self.filepath
554
572
  result.add_testsuite(self)
555
573
  result.add_testsuite(other)
@@ -636,11 +654,10 @@ class TestSuite(Element):
636
654
 
637
655
  def testsuites(self):
638
656
  """Iterate through all testsuites."""
639
- for suite in self.iterchildren(TestSuite):
640
- yield suite
657
+ yield from self.iterchildren(type(self))
641
658
 
642
- def write(self, filepath: str = None, pretty=False):
643
- 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)
644
661
 
645
662
 
646
663
  class JUnitXml(Element):
@@ -665,19 +682,21 @@ class JUnitXml(Element):
665
682
  errors = IntAttr()
666
683
  skipped = IntAttr()
667
684
 
685
+ testsuite = TestSuite
686
+
668
687
  def __init__(self, name=None):
669
688
  super().__init__(self._tag)
670
689
  self.filepath = None
671
690
  self.name = name
672
691
 
673
- def __iter__(self):
674
- return super().iterchildren(TestSuite)
692
+ def __iter__(self) -> Iterator[TestSuite]:
693
+ return super().iterchildren(self.testsuite)
675
694
 
676
695
  def __len__(self):
677
696
  return len(list(self.__iter__()))
678
697
 
679
698
  def __add__(self, other):
680
- result = JUnitXml()
699
+ result = type(self)()
681
700
  for suite in self:
682
701
  result.add_testsuite(suite)
683
702
  for suite in other:
@@ -689,7 +708,7 @@ class JUnitXml(Element):
689
708
  for suite in other:
690
709
  self.add_testsuite(suite)
691
710
  elif other._elem.tag == "testsuite":
692
- suite = TestSuite(name=other.name)
711
+ suite = self.testsuite(name=other.name)
693
712
  for case in other:
694
713
  suite._add_testcase_no_update_stats(case)
695
714
  self.add_testsuite(suite)
@@ -724,39 +743,49 @@ class JUnitXml(Element):
724
743
  self.time = round(time, 3)
725
744
 
726
745
  @classmethod
727
- def fromroot(cls, root_elem: Element):
746
+ def fromroot(cls, root_elem: Element) -> "JUnitXml":
728
747
  """Construct JUnit objects from an elementTree root element."""
729
- if root_elem.tag == "testsuites":
730
- instance = cls()
731
- elif root_elem.tag == "testsuite":
732
- instance = TestSuite()
733
- 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":
734
754
  raise JUnitXmlError("Invalid format.")
735
755
  instance._elem = root_elem
736
756
  return instance
737
757
 
738
758
  @classmethod
739
- def fromstring(cls, text: str):
740
- """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)."""
741
761
  root_elem = etree.fromstring(text) # nosec
742
762
  return cls.fromroot(root_elem)
743
763
 
744
764
  @classmethod
745
- def fromfile(cls, filepath: str, parse_func=None):
746
- """Initiate the object from a report file."""
747
- if parse_func:
748
- 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)
749
778
  else:
750
- tree = etree.parse(filepath) # nosec
779
+ tree = etree.parse(file) # nosec
751
780
  root_elem = tree.getroot()
752
781
  instance = cls.fromroot(root_elem)
753
- instance.filepath = filepath
782
+ instance.filepath = file if isinstance(file, str) else None
754
783
  return instance
755
784
 
756
- 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):
757
786
  """Write the object into a JUnit XML file.
758
787
 
759
- 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.
760
789
  If `pretty` is True, the result file will be more human friendly.
761
790
  """
762
- write_xml(self, filepath=filepath, pretty=pretty, to_console=to_console)
791
+ write_xml(self, file_or_filename=file_or_filename, pretty=pretty)