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 20, 2024
1 parent c5c214a commit b042929
Show file tree
Hide file tree
Showing 6 changed files with 354 additions and 67 deletions.
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ 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",
"types-lxml",
]
report-polarion = [
"tmt[report-junit]",
Expand Down Expand Up @@ -216,7 +218,7 @@ module = [
"guestfs.*",
"html2text.*",
"fmf.*",
"junit_xml.*",
"lxml.*",
"libvirt.*",
"nitrate.*",
"pylero.*",
Expand Down
291 changes: 230 additions & 61 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 TYPE_CHECKING, Any, Optional, overload

from jinja2 import FileSystemLoader, select_autoescape

import tmt
import tmt.base
Expand All @@ -9,33 +13,18 @@
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 +47,184 @@ 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()
phase: 'ReportPlugin[ReportJUnitData]|ReportPlugin[ReportPolarionData]',
flavor: str = DEFAULT_FLAVOR_NAME,
template_path: Optional[Path] = None,
include_output_log: bool = True,
prettify: bool = True,
results_context: Optional[ResultsContext] = None,
**extra_variables: Any
) -> str:
"""
Create JUnit XML file and return the data
:param phase: 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.
:param include_output_log: if enabled, the ``<system-out>`` tags are included
in the final template output.
:param prettify: allows to control the XML pretty print.
:param results_context: if set, the provided ResultsContext is used in a template.
:param 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(phase.step.plan.execute.results())

# Prepare the template environment
environment = default_template_environment()

template_dir = Path('steps/report/junit/templates/')
template_path = template_path or tmt.utils.resource_files(
template_dir / Path(f'{flavor}.xml.j2'))

# Use a FileSystemLoader for a non-custom flavor
if flavor != CUSTOM_FLAVOR_NAME:
environment.loader = FileSystemLoader(
searchpath=tmt.utils.resource_files(template_dir))

def _read_log(log: Path) -> str:
""" Read the contents of a given result log """
try:
return str(phase.step.plan.execute.read(log))
except AttributeError:
return ''

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.j2'))

suite = junit_xml.TestSuite(report.step.plan.name)
xml_data = render_template_file(
template_path,
environment,
RESULTS=results_context,
PLAN=phase.step.plan,
INCLUDE_OUTPUT_LOG=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': prettify,
'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:
phase.warn(
f"The '{CUSTOM_FLAVOR_NAME}' JUnit flavor is used, you are solely responsible "
"for the validity of the XML schema.")

phase.warn(f"The pretty print is always disabled for '{CUSTOM_FLAVOR_NAME}' JUnit "
"flavor")

prettify = xml_parser_kwargs['remove_blank_text'] = False

xml_parser = etree.XMLParser(**xml_parser_kwargs)

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

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

for result in report.step.plan.execute.results():
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
# 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:
phase.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:
phase.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 str(xml_output.decode('utf-8'))

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


@dataclasses.dataclass
Expand All @@ -101,7 +234,29 @@ class ReportJUnitData(tmt.steps.report.ReportStepData):
option='--file',
metavar='PATH',
help='Path to the file to store JUnit to.',
normalize=lambda key_address, raw_value, logger: Path(raw_value) if raw_value else None)
normalize=tmt.utils.normalize_path)

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)

prettify: bool = field(
default=True,
option=('--prettify / --no-prettify'),
is_flag=True,
show_default=True,
help="Enable the XML pretty print for generated JUnit file. This option is always "
f"disabled for '{CUSTOM_FLAVOR_NAME}' template flavor.")

include_output_log: bool = field(
default=True,
Expand All @@ -114,33 +269,47 @@ 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(
phase=self,
flavor=self.data.flavor,
template_path=self.data.template_path,
include_output_log=self.data.include_output_log,
prettify=self.data.prettify)
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 b042929

Please sign in to comment.