You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

701 lines
25KB

  1. """Report test results in JUnit-XML format, for use with Jenkins and build
  2. integration servers.
  3. Based on initial code from Ross Lawley.
  4. Output conforms to
  5. https://github.com/jenkinsci/xunit-plugin/blob/master/src/main/resources/org/jenkinsci/plugins/xunit/types/model/xsd/junit-10.xsd
  6. """
  7. import functools
  8. import os
  9. import platform
  10. import re
  11. import xml.etree.ElementTree as ET
  12. from datetime import datetime
  13. from typing import Callable
  14. from typing import Dict
  15. from typing import List
  16. from typing import Match
  17. from typing import Optional
  18. from typing import Tuple
  19. from typing import Union
  20. import pytest
  21. from _pytest import nodes
  22. from _pytest import timing
  23. from _pytest._code.code import ExceptionRepr
  24. from _pytest._code.code import ReprFileLocation
  25. from _pytest.config import Config
  26. from _pytest.config import filename_arg
  27. from _pytest.config.argparsing import Parser
  28. from _pytest.fixtures import FixtureRequest
  29. from _pytest.reports import TestReport
  30. from _pytest.store import StoreKey
  31. from _pytest.terminal import TerminalReporter
  32. xml_key = StoreKey["LogXML"]()
  33. def bin_xml_escape(arg: object) -> str:
  34. r"""Visually escape invalid XML characters.
  35. For example, transforms
  36. 'hello\aworld\b'
  37. into
  38. 'hello#x07world#x08'
  39. Note that the #xABs are *not* XML escapes - missing the ampersand &#xAB.
  40. The idea is to escape visually for the user rather than for XML itself.
  41. """
  42. def repl(matchobj: Match[str]) -> str:
  43. i = ord(matchobj.group())
  44. if i <= 0xFF:
  45. return "#x%02X" % i
  46. else:
  47. return "#x%04X" % i
  48. # The spec range of valid chars is:
  49. # Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
  50. # For an unknown(?) reason, we disallow #x7F (DEL) as well.
  51. illegal_xml_re = (
  52. "[^\u0009\u000A\u000D\u0020-\u007E\u0080-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]"
  53. )
  54. return re.sub(illegal_xml_re, repl, str(arg))
  55. def merge_family(left, right) -> None:
  56. result = {}
  57. for kl, vl in left.items():
  58. for kr, vr in right.items():
  59. if not isinstance(vl, list):
  60. raise TypeError(type(vl))
  61. result[kl] = vl + vr
  62. left.update(result)
  63. families = {}
  64. families["_base"] = {"testcase": ["classname", "name"]}
  65. families["_base_legacy"] = {"testcase": ["file", "line", "url"]}
  66. # xUnit 1.x inherits legacy attributes.
  67. families["xunit1"] = families["_base"].copy()
  68. merge_family(families["xunit1"], families["_base_legacy"])
  69. # xUnit 2.x uses strict base attributes.
  70. families["xunit2"] = families["_base"]
  71. class _NodeReporter:
  72. def __init__(self, nodeid: Union[str, TestReport], xml: "LogXML") -> None:
  73. self.id = nodeid
  74. self.xml = xml
  75. self.add_stats = self.xml.add_stats
  76. self.family = self.xml.family
  77. self.duration = 0
  78. self.properties: List[Tuple[str, str]] = []
  79. self.nodes: List[ET.Element] = []
  80. self.attrs: Dict[str, str] = {}
  81. def append(self, node: ET.Element) -> None:
  82. self.xml.add_stats(node.tag)
  83. self.nodes.append(node)
  84. def add_property(self, name: str, value: object) -> None:
  85. self.properties.append((str(name), bin_xml_escape(value)))
  86. def add_attribute(self, name: str, value: object) -> None:
  87. self.attrs[str(name)] = bin_xml_escape(value)
  88. def make_properties_node(self) -> Optional[ET.Element]:
  89. """Return a Junit node containing custom properties, if any."""
  90. if self.properties:
  91. properties = ET.Element("properties")
  92. for name, value in self.properties:
  93. properties.append(ET.Element("property", name=name, value=value))
  94. return properties
  95. return None
  96. def record_testreport(self, testreport: TestReport) -> None:
  97. names = mangle_test_address(testreport.nodeid)
  98. existing_attrs = self.attrs
  99. classnames = names[:-1]
  100. if self.xml.prefix:
  101. classnames.insert(0, self.xml.prefix)
  102. attrs: Dict[str, str] = {
  103. "classname": ".".join(classnames),
  104. "name": bin_xml_escape(names[-1]),
  105. "file": testreport.location[0],
  106. }
  107. if testreport.location[1] is not None:
  108. attrs["line"] = str(testreport.location[1])
  109. if hasattr(testreport, "url"):
  110. attrs["url"] = testreport.url
  111. self.attrs = attrs
  112. self.attrs.update(existing_attrs) # Restore any user-defined attributes.
  113. # Preserve legacy testcase behavior.
  114. if self.family == "xunit1":
  115. return
  116. # Filter out attributes not permitted by this test family.
  117. # Including custom attributes because they are not valid here.
  118. temp_attrs = {}
  119. for key in self.attrs.keys():
  120. if key in families[self.family]["testcase"]:
  121. temp_attrs[key] = self.attrs[key]
  122. self.attrs = temp_attrs
  123. def to_xml(self) -> ET.Element:
  124. testcase = ET.Element("testcase", self.attrs, time="%.3f" % self.duration)
  125. properties = self.make_properties_node()
  126. if properties is not None:
  127. testcase.append(properties)
  128. testcase.extend(self.nodes)
  129. return testcase
  130. def _add_simple(self, tag: str, message: str, data: Optional[str] = None) -> None:
  131. node = ET.Element(tag, message=message)
  132. node.text = bin_xml_escape(data)
  133. self.append(node)
  134. def write_captured_output(self, report: TestReport) -> None:
  135. if not self.xml.log_passing_tests and report.passed:
  136. return
  137. content_out = report.capstdout
  138. content_log = report.caplog
  139. content_err = report.capstderr
  140. if self.xml.logging == "no":
  141. return
  142. content_all = ""
  143. if self.xml.logging in ["log", "all"]:
  144. content_all = self._prepare_content(content_log, " Captured Log ")
  145. if self.xml.logging in ["system-out", "out-err", "all"]:
  146. content_all += self._prepare_content(content_out, " Captured Out ")
  147. self._write_content(report, content_all, "system-out")
  148. content_all = ""
  149. if self.xml.logging in ["system-err", "out-err", "all"]:
  150. content_all += self._prepare_content(content_err, " Captured Err ")
  151. self._write_content(report, content_all, "system-err")
  152. content_all = ""
  153. if content_all:
  154. self._write_content(report, content_all, "system-out")
  155. def _prepare_content(self, content: str, header: str) -> str:
  156. return "\n".join([header.center(80, "-"), content, ""])
  157. def _write_content(self, report: TestReport, content: str, jheader: str) -> None:
  158. tag = ET.Element(jheader)
  159. tag.text = bin_xml_escape(content)
  160. self.append(tag)
  161. def append_pass(self, report: TestReport) -> None:
  162. self.add_stats("passed")
  163. def append_failure(self, report: TestReport) -> None:
  164. # msg = str(report.longrepr.reprtraceback.extraline)
  165. if hasattr(report, "wasxfail"):
  166. self._add_simple("skipped", "xfail-marked test passes unexpectedly")
  167. else:
  168. assert report.longrepr is not None
  169. reprcrash: Optional[ReprFileLocation] = getattr(
  170. report.longrepr, "reprcrash", None
  171. )
  172. if reprcrash is not None:
  173. message = reprcrash.message
  174. else:
  175. message = str(report.longrepr)
  176. message = bin_xml_escape(message)
  177. self._add_simple("failure", message, str(report.longrepr))
  178. def append_collect_error(self, report: TestReport) -> None:
  179. # msg = str(report.longrepr.reprtraceback.extraline)
  180. assert report.longrepr is not None
  181. self._add_simple("error", "collection failure", str(report.longrepr))
  182. def append_collect_skipped(self, report: TestReport) -> None:
  183. self._add_simple("skipped", "collection skipped", str(report.longrepr))
  184. def append_error(self, report: TestReport) -> None:
  185. assert report.longrepr is not None
  186. reprcrash: Optional[ReprFileLocation] = getattr(
  187. report.longrepr, "reprcrash", None
  188. )
  189. if reprcrash is not None:
  190. reason = reprcrash.message
  191. else:
  192. reason = str(report.longrepr)
  193. if report.when == "teardown":
  194. msg = f'failed on teardown with "{reason}"'
  195. else:
  196. msg = f'failed on setup with "{reason}"'
  197. self._add_simple("error", msg, str(report.longrepr))
  198. def append_skipped(self, report: TestReport) -> None:
  199. if hasattr(report, "wasxfail"):
  200. xfailreason = report.wasxfail
  201. if xfailreason.startswith("reason: "):
  202. xfailreason = xfailreason[8:]
  203. xfailreason = bin_xml_escape(xfailreason)
  204. skipped = ET.Element("skipped", type="pytest.xfail", message=xfailreason)
  205. self.append(skipped)
  206. else:
  207. assert isinstance(report.longrepr, tuple)
  208. filename, lineno, skipreason = report.longrepr
  209. if skipreason.startswith("Skipped: "):
  210. skipreason = skipreason[9:]
  211. details = f"{filename}:{lineno}: {skipreason}"
  212. skipped = ET.Element("skipped", type="pytest.skip", message=skipreason)
  213. skipped.text = bin_xml_escape(details)
  214. self.append(skipped)
  215. self.write_captured_output(report)
  216. def finalize(self) -> None:
  217. data = self.to_xml()
  218. self.__dict__.clear()
  219. # Type ignored becuase mypy doesn't like overriding a method.
  220. # Also the return value doesn't match...
  221. self.to_xml = lambda: data # type: ignore[assignment]
  222. def _warn_incompatibility_with_xunit2(
  223. request: FixtureRequest, fixture_name: str
  224. ) -> None:
  225. """Emit a PytestWarning about the given fixture being incompatible with newer xunit revisions."""
  226. from _pytest.warning_types import PytestWarning
  227. xml = request.config._store.get(xml_key, None)
  228. if xml is not None and xml.family not in ("xunit1", "legacy"):
  229. request.node.warn(
  230. PytestWarning(
  231. "{fixture_name} is incompatible with junit_family '{family}' (use 'legacy' or 'xunit1')".format(
  232. fixture_name=fixture_name, family=xml.family
  233. )
  234. )
  235. )
  236. @pytest.fixture
  237. def record_property(request: FixtureRequest) -> Callable[[str, object], None]:
  238. """Add extra properties to the calling test.
  239. User properties become part of the test report and are available to the
  240. configured reporters, like JUnit XML.
  241. The fixture is callable with ``name, value``. The value is automatically
  242. XML-encoded.
  243. Example::
  244. def test_function(record_property):
  245. record_property("example_key", 1)
  246. """
  247. _warn_incompatibility_with_xunit2(request, "record_property")
  248. def append_property(name: str, value: object) -> None:
  249. request.node.user_properties.append((name, value))
  250. return append_property
  251. @pytest.fixture
  252. def record_xml_attribute(request: FixtureRequest) -> Callable[[str, object], None]:
  253. """Add extra xml attributes to the tag for the calling test.
  254. The fixture is callable with ``name, value``. The value is
  255. automatically XML-encoded.
  256. """
  257. from _pytest.warning_types import PytestExperimentalApiWarning
  258. request.node.warn(
  259. PytestExperimentalApiWarning("record_xml_attribute is an experimental feature")
  260. )
  261. _warn_incompatibility_with_xunit2(request, "record_xml_attribute")
  262. # Declare noop
  263. def add_attr_noop(name: str, value: object) -> None:
  264. pass
  265. attr_func = add_attr_noop
  266. xml = request.config._store.get(xml_key, None)
  267. if xml is not None:
  268. node_reporter = xml.node_reporter(request.node.nodeid)
  269. attr_func = node_reporter.add_attribute
  270. return attr_func
  271. def _check_record_param_type(param: str, v: str) -> None:
  272. """Used by record_testsuite_property to check that the given parameter name is of the proper
  273. type."""
  274. __tracebackhide__ = True
  275. if not isinstance(v, str):
  276. msg = "{param} parameter needs to be a string, but {g} given" # type: ignore[unreachable]
  277. raise TypeError(msg.format(param=param, g=type(v).__name__))
  278. @pytest.fixture(scope="session")
  279. def record_testsuite_property(request: FixtureRequest) -> Callable[[str, object], None]:
  280. """Record a new ``<property>`` tag as child of the root ``<testsuite>``.
  281. This is suitable to writing global information regarding the entire test
  282. suite, and is compatible with ``xunit2`` JUnit family.
  283. This is a ``session``-scoped fixture which is called with ``(name, value)``. Example:
  284. .. code-block:: python
  285. def test_foo(record_testsuite_property):
  286. record_testsuite_property("ARCH", "PPC")
  287. record_testsuite_property("STORAGE_TYPE", "CEPH")
  288. ``name`` must be a string, ``value`` will be converted to a string and properly xml-escaped.
  289. .. warning::
  290. Currently this fixture **does not work** with the
  291. `pytest-xdist <https://github.com/pytest-dev/pytest-xdist>`__ plugin. See issue
  292. `#7767 <https://github.com/pytest-dev/pytest/issues/7767>`__ for details.
  293. """
  294. __tracebackhide__ = True
  295. def record_func(name: str, value: object) -> None:
  296. """No-op function in case --junitxml was not passed in the command-line."""
  297. __tracebackhide__ = True
  298. _check_record_param_type("name", name)
  299. xml = request.config._store.get(xml_key, None)
  300. if xml is not None:
  301. record_func = xml.add_global_property # noqa
  302. return record_func
  303. def pytest_addoption(parser: Parser) -> None:
  304. group = parser.getgroup("terminal reporting")
  305. group.addoption(
  306. "--junitxml",
  307. "--junit-xml",
  308. action="store",
  309. dest="xmlpath",
  310. metavar="path",
  311. type=functools.partial(filename_arg, optname="--junitxml"),
  312. default=None,
  313. help="create junit-xml style report file at given path.",
  314. )
  315. group.addoption(
  316. "--junitprefix",
  317. "--junit-prefix",
  318. action="store",
  319. metavar="str",
  320. default=None,
  321. help="prepend prefix to classnames in junit-xml output",
  322. )
  323. parser.addini(
  324. "junit_suite_name", "Test suite name for JUnit report", default="pytest"
  325. )
  326. parser.addini(
  327. "junit_logging",
  328. "Write captured log messages to JUnit report: "
  329. "one of no|log|system-out|system-err|out-err|all",
  330. default="no",
  331. )
  332. parser.addini(
  333. "junit_log_passing_tests",
  334. "Capture log information for passing tests to JUnit report: ",
  335. type="bool",
  336. default=True,
  337. )
  338. parser.addini(
  339. "junit_duration_report",
  340. "Duration time to report: one of total|call",
  341. default="total",
  342. ) # choices=['total', 'call'])
  343. parser.addini(
  344. "junit_family",
  345. "Emit XML for schema: one of legacy|xunit1|xunit2",
  346. default="xunit2",
  347. )
  348. def pytest_configure(config: Config) -> None:
  349. xmlpath = config.option.xmlpath
  350. # Prevent opening xmllog on worker nodes (xdist).
  351. if xmlpath and not hasattr(config, "workerinput"):
  352. junit_family = config.getini("junit_family")
  353. config._store[xml_key] = LogXML(
  354. xmlpath,
  355. config.option.junitprefix,
  356. config.getini("junit_suite_name"),
  357. config.getini("junit_logging"),
  358. config.getini("junit_duration_report"),
  359. junit_family,
  360. config.getini("junit_log_passing_tests"),
  361. )
  362. config.pluginmanager.register(config._store[xml_key])
  363. def pytest_unconfigure(config: Config) -> None:
  364. xml = config._store.get(xml_key, None)
  365. if xml:
  366. del config._store[xml_key]
  367. config.pluginmanager.unregister(xml)
  368. def mangle_test_address(address: str) -> List[str]:
  369. path, possible_open_bracket, params = address.partition("[")
  370. names = path.split("::")
  371. try:
  372. names.remove("()")
  373. except ValueError:
  374. pass
  375. # Convert file path to dotted path.
  376. names[0] = names[0].replace(nodes.SEP, ".")
  377. names[0] = re.sub(r"\.py$", "", names[0])
  378. # Put any params back.
  379. names[-1] += possible_open_bracket + params
  380. return names
  381. class LogXML:
  382. def __init__(
  383. self,
  384. logfile,
  385. prefix: Optional[str],
  386. suite_name: str = "pytest",
  387. logging: str = "no",
  388. report_duration: str = "total",
  389. family="xunit1",
  390. log_passing_tests: bool = True,
  391. ) -> None:
  392. logfile = os.path.expanduser(os.path.expandvars(logfile))
  393. self.logfile = os.path.normpath(os.path.abspath(logfile))
  394. self.prefix = prefix
  395. self.suite_name = suite_name
  396. self.logging = logging
  397. self.log_passing_tests = log_passing_tests
  398. self.report_duration = report_duration
  399. self.family = family
  400. self.stats: Dict[str, int] = dict.fromkeys(
  401. ["error", "passed", "failure", "skipped"], 0
  402. )
  403. self.node_reporters: Dict[
  404. Tuple[Union[str, TestReport], object], _NodeReporter
  405. ] = ({})
  406. self.node_reporters_ordered: List[_NodeReporter] = []
  407. self.global_properties: List[Tuple[str, str]] = []
  408. # List of reports that failed on call but teardown is pending.
  409. self.open_reports: List[TestReport] = []
  410. self.cnt_double_fail_tests = 0
  411. # Replaces convenience family with real family.
  412. if self.family == "legacy":
  413. self.family = "xunit1"
  414. def finalize(self, report: TestReport) -> None:
  415. nodeid = getattr(report, "nodeid", report)
  416. # Local hack to handle xdist report order.
  417. workernode = getattr(report, "node", None)
  418. reporter = self.node_reporters.pop((nodeid, workernode))
  419. if reporter is not None:
  420. reporter.finalize()
  421. def node_reporter(self, report: Union[TestReport, str]) -> _NodeReporter:
  422. nodeid: Union[str, TestReport] = getattr(report, "nodeid", report)
  423. # Local hack to handle xdist report order.
  424. workernode = getattr(report, "node", None)
  425. key = nodeid, workernode
  426. if key in self.node_reporters:
  427. # TODO: breaks for --dist=each
  428. return self.node_reporters[key]
  429. reporter = _NodeReporter(nodeid, self)
  430. self.node_reporters[key] = reporter
  431. self.node_reporters_ordered.append(reporter)
  432. return reporter
  433. def add_stats(self, key: str) -> None:
  434. if key in self.stats:
  435. self.stats[key] += 1
  436. def _opentestcase(self, report: TestReport) -> _NodeReporter:
  437. reporter = self.node_reporter(report)
  438. reporter.record_testreport(report)
  439. return reporter
  440. def pytest_runtest_logreport(self, report: TestReport) -> None:
  441. """Handle a setup/call/teardown report, generating the appropriate
  442. XML tags as necessary.
  443. Note: due to plugins like xdist, this hook may be called in interlaced
  444. order with reports from other nodes. For example:
  445. Usual call order:
  446. -> setup node1
  447. -> call node1
  448. -> teardown node1
  449. -> setup node2
  450. -> call node2
  451. -> teardown node2
  452. Possible call order in xdist:
  453. -> setup node1
  454. -> call node1
  455. -> setup node2
  456. -> call node2
  457. -> teardown node2
  458. -> teardown node1
  459. """
  460. close_report = None
  461. if report.passed:
  462. if report.when == "call": # ignore setup/teardown
  463. reporter = self._opentestcase(report)
  464. reporter.append_pass(report)
  465. elif report.failed:
  466. if report.when == "teardown":
  467. # The following vars are needed when xdist plugin is used.
  468. report_wid = getattr(report, "worker_id", None)
  469. report_ii = getattr(report, "item_index", None)
  470. close_report = next(
  471. (
  472. rep
  473. for rep in self.open_reports
  474. if (
  475. rep.nodeid == report.nodeid
  476. and getattr(rep, "item_index", None) == report_ii
  477. and getattr(rep, "worker_id", None) == report_wid
  478. )
  479. ),
  480. None,
  481. )
  482. if close_report:
  483. # We need to open new testcase in case we have failure in
  484. # call and error in teardown in order to follow junit
  485. # schema.
  486. self.finalize(close_report)
  487. self.cnt_double_fail_tests += 1
  488. reporter = self._opentestcase(report)
  489. if report.when == "call":
  490. reporter.append_failure(report)
  491. self.open_reports.append(report)
  492. if not self.log_passing_tests:
  493. reporter.write_captured_output(report)
  494. else:
  495. reporter.append_error(report)
  496. elif report.skipped:
  497. reporter = self._opentestcase(report)
  498. reporter.append_skipped(report)
  499. self.update_testcase_duration(report)
  500. if report.when == "teardown":
  501. reporter = self._opentestcase(report)
  502. reporter.write_captured_output(report)
  503. for propname, propvalue in report.user_properties:
  504. reporter.add_property(propname, str(propvalue))
  505. self.finalize(report)
  506. report_wid = getattr(report, "worker_id", None)
  507. report_ii = getattr(report, "item_index", None)
  508. close_report = next(
  509. (
  510. rep
  511. for rep in self.open_reports
  512. if (
  513. rep.nodeid == report.nodeid
  514. and getattr(rep, "item_index", None) == report_ii
  515. and getattr(rep, "worker_id", None) == report_wid
  516. )
  517. ),
  518. None,
  519. )
  520. if close_report:
  521. self.open_reports.remove(close_report)
  522. def update_testcase_duration(self, report: TestReport) -> None:
  523. """Accumulate total duration for nodeid from given report and update
  524. the Junit.testcase with the new total if already created."""
  525. if self.report_duration == "total" or report.when == self.report_duration:
  526. reporter = self.node_reporter(report)
  527. reporter.duration += getattr(report, "duration", 0.0)
  528. def pytest_collectreport(self, report: TestReport) -> None:
  529. if not report.passed:
  530. reporter = self._opentestcase(report)
  531. if report.failed:
  532. reporter.append_collect_error(report)
  533. else:
  534. reporter.append_collect_skipped(report)
  535. def pytest_internalerror(self, excrepr: ExceptionRepr) -> None:
  536. reporter = self.node_reporter("internal")
  537. reporter.attrs.update(classname="pytest", name="internal")
  538. reporter._add_simple("error", "internal error", str(excrepr))
  539. def pytest_sessionstart(self) -> None:
  540. self.suite_start_time = timing.time()
  541. def pytest_sessionfinish(self) -> None:
  542. dirname = os.path.dirname(os.path.abspath(self.logfile))
  543. if not os.path.isdir(dirname):
  544. os.makedirs(dirname)
  545. logfile = open(self.logfile, "w", encoding="utf-8")
  546. suite_stop_time = timing.time()
  547. suite_time_delta = suite_stop_time - self.suite_start_time
  548. numtests = (
  549. self.stats["passed"]
  550. + self.stats["failure"]
  551. + self.stats["skipped"]
  552. + self.stats["error"]
  553. - self.cnt_double_fail_tests
  554. )
  555. logfile.write('<?xml version="1.0" encoding="utf-8"?>')
  556. suite_node = ET.Element(
  557. "testsuite",
  558. name=self.suite_name,
  559. errors=str(self.stats["error"]),
  560. failures=str(self.stats["failure"]),
  561. skipped=str(self.stats["skipped"]),
  562. tests=str(numtests),
  563. time="%.3f" % suite_time_delta,
  564. timestamp=datetime.fromtimestamp(self.suite_start_time).isoformat(),
  565. hostname=platform.node(),
  566. )
  567. global_properties = self._get_global_properties_node()
  568. if global_properties is not None:
  569. suite_node.append(global_properties)
  570. for node_reporter in self.node_reporters_ordered:
  571. suite_node.append(node_reporter.to_xml())
  572. testsuites = ET.Element("testsuites")
  573. testsuites.append(suite_node)
  574. logfile.write(ET.tostring(testsuites, encoding="unicode"))
  575. logfile.close()
  576. def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None:
  577. terminalreporter.write_sep("-", f"generated xml file: {self.logfile}")
  578. def add_global_property(self, name: str, value: object) -> None:
  579. __tracebackhide__ = True
  580. _check_record_param_type("name", name)
  581. self.global_properties.append((name, bin_xml_escape(value)))
  582. def _get_global_properties_node(self) -> Optional[ET.Element]:
  583. """Return a Junit node containing custom properties, if any."""
  584. if self.global_properties:
  585. properties = ET.Element("properties")
  586. for name, value in self.global_properties:
  587. properties.append(ET.Element("property", name=name, value=value))
  588. return properties
  589. return None