Skip to content

Commit

Permalink
Generate JUnit files using Jinja2 instead of junit-xml
Browse files Browse the repository at this point in the history
  • Loading branch information
seberm committed Aug 19, 2024
1 parent c5c214a commit 47307a9
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 71 deletions.
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ provision-virtual = [
]
provision-container = []
report-junit = [
"junit_xml>=1.9",
# Required by to support XML parsing and checking the XSD schemas.
"lxml>=4.9.0",
]
report-polarion = [
"tmt[report-junit]",
Expand Down Expand Up @@ -139,6 +140,9 @@ dependencies = [
"types-jinja2",
"types-babel",
"types-docutils",

# TODO: This probably shouldn't be here?
# "types-lxml",
]
features = ["all"]

Expand Down Expand Up @@ -216,7 +220,6 @@ module = [
"guestfs.*",
"html2text.*",
"fmf.*",
"junit_xml.*",
"libvirt.*",
"nitrate.*",
"pylero.*",
Expand Down
270 changes: 205 additions & 65 deletions tmt/steps/report/junit.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import dataclasses
from typing import TYPE_CHECKING, Optional, overload
import functools
from collections.abc import Iterator
from typing import Any, Optional, overload

from jinja2 import FileSystemLoader, select_autoescape

import tmt
import tmt.base
Expand All @@ -9,33 +13,13 @@
import tmt.steps
import tmt.steps.report
import tmt.utils
from tmt.plugins import ModuleImporter
from tmt.result import ResultOutcome
from tmt.utils import Path, field
from tmt.utils.templates import default_template_environment, render_template_file

if TYPE_CHECKING:
import junit_xml

from tmt.steps.report import ReportPlugin
from tmt.steps.report.polarion import ReportPolarionData

DEFAULT_NAME = "junit.xml"


# ignore[unused-ignore]: Pyright would report that "module cannot be
# used as a type", and it would be correct. On the other hand, it works,
# and both mypy and pyright are able to propagate the essence of a given
# module through `ModuleImporter` that, eventually, the module object
# returned by the importer does have all expected members.
#
# The error message does not have its own code, but simple `type: ignore`
# is enough to suppress it. And then mypy complains about an unused
# ignore, hence `unused-ignore` code, leading to apparently confusing
# directive.
import_junit_xml: ModuleImporter['junit_xml'] = ModuleImporter( # type: ignore[unused-ignore]
'junit_xml',
tmt.utils.ReportError,
"Missing 'junit-xml', fixable by 'pip install tmt[report-junit]'.",
tmt.log.Logger.get_bootstrap_logger())
DEFAULT_NAME = 'junit.xml'
DEFAULT_FLAVOR_NAME = 'default'
CUSTOM_FLAVOR_NAME = 'custom'


@overload
Expand All @@ -58,40 +42,170 @@ def duration_to_seconds(duration: Optional[str]) -> Optional[int]:
f"Malformed duration '{duration}' ({error}).")


class ResultsContext:
"""
A class which keeps the results context (especially the result summary) for
JUnit template.
"""

def __init__(self, results: list[tmt.Result]) -> None:
self._results = results

def __iter__(self) -> Iterator[tmt.Result]:
""" Possibility to iterate over results by iterating an instance """
return iter(self._results)

def __len__(self) -> int:
""" Returns the number of results """
return len(self._results)

@functools.cached_property
def executed(self) -> list[tmt.Result]:
""" Returns results of all executed tests """
return [r for r in self._results if r.result != ResultOutcome.INFO]

@functools.cached_property
def skipped(self) -> list[tmt.Result]:
""" Returns results of skipped tests """
return [r for r in self._results if r.result == ResultOutcome.INFO]

@functools.cached_property
def failed(self) -> list[tmt.Result]:
""" Returns results of failed tests """
return [r for r in self._results if r.result == ResultOutcome.FAIL]

@functools.cached_property
def errored(self) -> list[tmt.Result]:
""" Returns results of tests with error/warn outcome """
return [r for r in self._results if r.result in [
ResultOutcome.ERROR,
ResultOutcome.WARN]]

@functools.cached_property
def duration(self) -> int:
""" Returns the total duration of all tests in seconds """
return sum([duration_to_seconds(r.duration) or 0 for r in self._results])


def make_junit_xml(
report: 'ReportPlugin[ReportJUnitData]|ReportPlugin[ReportPolarionData]'
) -> 'junit_xml.TestSuite':
""" Create junit xml object """
junit_xml = import_junit_xml()
report: 'ReportPlugin[ReportJUnitData]|ReportPlugin[ReportPolarionData]',
flavor: str,
template_path: Optional[Path] = None,
results_context: Optional[ResultsContext] = None,
**extra_variables: Any
) -> str:
"""
Create JUnit XML file and return the data
suite = junit_xml.TestSuite(report.step.plan.name)
:param report: instance of a ReportPlugin.
:param flavor: name of a JUnit flavor to generate.
:param template_path: if set, the provided template will be used instead of
a pre-defined flavor template. In this case, the ``flavor`` must be set
to ``custom`` value.
:pawam extra_variables: if set, these variables get propagated into the
Jinja template.
"""

# Get the template context for TMT results
results_context = results_context or ResultsContext(report.step.plan.execute.results())

for result in report.step.plan.execute.results():
# Prepare the template environment
def _read_log(log: Path) -> str:
""" Read the contents of a given result log """
try:
main_log = report.step.plan.execute.read(result.log[0])
except (IndexError, AttributeError):
main_log = None
case = junit_xml.TestCase(
result.name,
classname=None,
elapsed_sec=duration_to_seconds(result.duration))

if report.data.include_output_log:
case.stdout = main_log

# Map tmt OUTCOME to JUnit states
if result.result == tmt.result.ResultOutcome.ERROR:
case.add_error_info(result.result.value, output=result.failures(main_log))
elif result.result == tmt.result.ResultOutcome.FAIL:
case.add_failure_info(result.result.value, output=result.failures(main_log))
elif result.result == tmt.result.ResultOutcome.INFO:
case.add_skipped_info(result.result.value, output=result.failures(main_log))
elif result.result == tmt.result.ResultOutcome.WARN:
case.add_error_info(result.result.value, output=result.failures(main_log))
# Passed state is the default
suite.test_cases.append(case)

return suite
return str(report.step.plan.execute.read(log))
except AttributeError:
return ''

environment = default_template_environment()

# Use a FileSystemLoader for a non-custom flavor
if flavor != CUSTOM_FLAVOR_NAME:
environment.loader = FileSystemLoader(searchpath=Path('tmt/steps/report/junit/templates/'))
template_path = Path(tmt.utils.resource_files(
Path(f'steps/report/junit/templates/{flavor}.xml.j2')))

environment.filters.update({
'read_log': _read_log,
'duration_to_seconds': duration_to_seconds,
})

# Explicitly enable the autoescape because it's disabled by default by TMT
# (see /teemtee/tmt/issues/2873 for more info.
environment.autoescape = select_autoescape(enabled_extensions=('xml'))

xml_data = render_template_file(
template_path,
environment,
RESULTS=results_context,
PLAN=report.step.plan,
INCLUDE_OUTPUT_LOG=report.data.include_output_log,
**extra_variables)

# Try to use lxml to check the flavor XML schema and prettify the final XML
# output.
try:
from lxml import etree

xml_parser_kwargs = {
'remove_blank_text': True,
'schema': None,
}

# The schema check must be done only for a non-custom JUnit flavors
if flavor != CUSTOM_FLAVOR_NAME:
xsd_schema_path = Path(tmt.utils.resource_files(
Path(f'steps/report/junit/schemas/{flavor}.xsd')))

with open(xsd_schema_path, 'rb') as fd:
schema_root = etree.XML(fd.read())
xml_parser_kwargs['schema'] = etree.XMLSchema(schema_root)
else:
report.warn(
f"The '{CUSTOM_FLAVOR_NAME}' JUnit flavor is used, you are solely responsible "
"for the validity of the XML schema.")

xml_parser = etree.XMLParser(**xml_parser_kwargs)

to_string_common_kwargs = {
'xml_declaration': True,
'pretty_print': True,

# The 'utf-8' encoding must be used instead of 'unicode', otherwise
# the XML declaration is not included in the output.
'encoding': 'utf-8',
}

try:
# S320: Parsing of untrusted data is known to be vulnerable to XML
# attacks.
xml_output = etree.tostring(
etree.fromstring(xml_data, xml_parser), # noqa: S320
**to_string_common_kwargs
)
except etree.XMLSyntaxError as e:
report.warn(
'The generated XML output is not valid against the XSD schema. Please, report '
'this problem to project maintainers.')
for err in e.error_log:
report.warn(err)

# Return the prettified XML without checking the XSD
del xml_parser_kwargs['schema']
xml_output = etree.tostring(
etree.fromstring( # noqa: S320
xml_data, etree.XMLParser(**xml_parser_kwargs),
),
**to_string_common_kwargs,
)

return xml_output.decode('utf-8')

except ImportError:
report.warn(
"Install 'tmt[report-junit]' to support neater JUnit XML output and the schema "
"validation against XSD.")
return xml_data


@dataclasses.dataclass
Expand All @@ -103,6 +217,20 @@ class ReportJUnitData(tmt.steps.report.ReportStepData):
help='Path to the file to store JUnit to.',
normalize=lambda key_address, raw_value, logger: Path(raw_value) if raw_value else None)

flavor: str = field(
default=DEFAULT_FLAVOR_NAME,
option='--flavor',
metavar='FLAVOR',
choices=[DEFAULT_FLAVOR_NAME, CUSTOM_FLAVOR_NAME],
help="Name of a JUnit flavor to generate. By default, the 'default' flavor is used.")

template_path: Optional[Path] = field(
default=None,
option='--template-path',
metavar='TEMPLATE_PATH',
help='Path to a custom template file to use for JUnit creation.',
normalize=tmt.utils.normalize_path)

include_output_log: bool = field(
default=True,
option=('--include-output-log / --no-include-output-log'),
Expand All @@ -114,33 +242,45 @@ class ReportJUnitData(tmt.steps.report.ReportStepData):
@tmt.steps.provides_method('junit')
class ReportJUnit(tmt.steps.report.ReportPlugin[ReportJUnitData]):
"""
Save test results in JUnit format.
Save test results in chosen JUnit flavor format. When flavor is set to
custom, the ``template-path`` with a path to a custom template must be
provided.
When ``file`` is not specified, output is written into a file
named ``junit.xml`` located in the current workdir.
"""

_data_class = ReportJUnitData

def check_options(self) -> None:
""" Check the module options """

if self.data.flavor == 'custom' and not self.data.template_path:
raise tmt.utils.ReportError(
"The 'custom' flavor requires the '--template-path' argument.")

if self.data.flavor != 'custom' and self.data.template_path:
raise tmt.utils.ReportError(
"The '--template-path' can be used only with '--flavor=custom'.")

def prune(self, logger: tmt.log.Logger) -> None:
""" Do not prune generated junit report """

def go(self, *, logger: Optional[tmt.log.Logger] = None) -> None:
""" Read executed tests and write junit """
super().go(logger=logger)

junit_xml = import_junit_xml()
suite = make_junit_xml(self)
self.check_options()

assert self.workdir is not None
f_path = self.data.file or self.workdir / DEFAULT_NAME

xml_data = make_junit_xml(
report=self,
flavor=self.data.flavor,
template_path=self.data.template_path)
try:
with open(f_path, 'w') as fw:
if hasattr(junit_xml, 'to_xml_report_file'):
junit_xml.to_xml_report_file(fw, [suite])
else:
# For older junit-xml
junit_xml.TestSuite.to_file(fw, [suite])
self.write(f_path, data=xml_data)
self.info("output", f_path, 'yellow')
except Exception as error:
raise tmt.utils.ReportError(
Expand Down
Loading

0 comments on commit 47307a9

Please sign in to comment.