Skip to content

Commit

Permalink
Merge branch 'mr/pmderodat/xunit-importer' into 'master'
Browse files Browse the repository at this point in the history
Make it possible to customize e3-convert-xunit without code duplication

See merge request it/e3-testsuite!35
  • Loading branch information
pmderodat committed Jul 18, 2024
2 parents 8ff23fc + 9c7108e commit 04f4862
Show file tree
Hide file tree
Showing 3 changed files with 265 additions and 47 deletions.
3 changes: 3 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
27.0 (Not released yet)
=======================

* `e3.testsuite.report.xunit`: add a `XUnitImportApp` class to make it possible
to create customized versions of the `e3-convert-xunit` script without code
duplication.
* `e3-opt-check`: new script to look for syntax errors in opt files.
* `e3-opt-parse`: exit gracefully and give line number information in case of
syntax error.
Expand Down
178 changes: 132 additions & 46 deletions src/e3/testsuite/report/xunit.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import itertools
import os
import re
from typing import Optional
from typing import Iterable, Optional
import unicodedata
import xml.etree.ElementTree as etree

Expand Down Expand Up @@ -343,6 +343,136 @@ def get_test_name(
)


class XUnitImporterApp:
"""
Helper class to implement a xUnit report import script.
This class provide the basic behavior, which subclasses can override if
needed.
"""

def __init__(self) -> None:
self.parser = argparse.ArgumentParser(
description="Convert a xUnit testsuite report to e3-testsuite's"
" format."
)
self.add_basic_options(self.parser)
self.add_options(self.parser)

self.args: argparse.Namespace
self.index: ReportIndex
self.xfails: dict[str, str]
self.importer: XUnitImporter

def add_basic_options(self, parser: argparse.ArgumentParser) -> None:
"""
Add basic command line arguments.
Subclasses must override this to replace basic command line arguments.
Note that most methods/properties in this class assume that these
arguments are present: not adding them will likely require to override
all methods/properties.
"""
parser.add_argument(
"--output",
"-o",
help="Output directory for the converted report. By default, use"
" the current working directory.",
)
parser.add_argument(
"--gaia-output",
action="store_true",
help="Output a GAIA-compatible testsuite report next to the YAML"
" report.",
)
parser.add_argument(
"--xfails",
help="YAML file that describes expected failures. If provided, it"
" must contain a mapping from test name to expected failure"
" messages.",
)
parser.add_argument(
"xml-report",
nargs="+",
help="xUnit XML reports to convert. If a directory is passed,"
" recursively look for all the files matching *.xml that it"
" contains.",
)

def add_options(self, parser: argparse.ArgumentParser) -> None:
"""
Add extra command line arguments.
Subclasses must override this to add command line arguments in addition
to the basic ones.
"""
pass

@property
def output_dir(self) -> str:
"""Return the report output directory."""
return self.args.output or "."

def create_output_report_index(self) -> ReportIndex:
"""Create the index for the report this app must write."""
result = ReportIndex(self.output_dir)
mkdir(result.results_dir)
return result

def get_xfails(self) -> dict[str, str]:
"""Return the "xfails" XUnitImporter constructor argument."""
return (
read_xfails_from_yaml(self.args.xfails) if self.args.xfails else {}
)

def create_importer(self) -> XUnitImporter:
"""Create the XUnitImporter instance for this app."""
return XUnitImporter(self.index, self.xfails)

def iter_xunit_files(self) -> Iterable[str]:
"""Iterate through all the xUnit report files to import."""
for path in getattr(self.args, "xml-report"):
if os.path.isdir(path):
for root, _, filenames in os.walk(path):
for f in filenames:
yield os.path.join(root, f)
else:
yield path

@property
def gaia_report_requested(self) -> bool:
"""Return whether a GAIA report was requested."""
return self.args.gaia_output

def tear_down(self) -> None:
"""
Clean up the importer.
Subclasses must override this for custom behavior before the app ends.
"""
pass

def run(self, argv: list[str] | None = None) -> None:
# Initialize the importer
self.args = self.parser.parse_args(argv)
self.index = self.create_output_report_index()
self.xfails = self.get_xfails()
self.importer = self.create_importer()

# Process xUnit report files
for filename in self.iter_xunit_files():
self.importer.run(filename)
self.importer.warn_dangling_xfails()

# Write reports
self.index.write()
if self.gaia_report_requested:
dump_gaia_report(self.index, self.index.results_dir)

# Clean up the importer
self.tear_down()


def read_xfails_from_yaml(filename: str) -> dict[str, str]:
"""
Read a XFAILs dict from a YAML file.
Expand All @@ -354,48 +484,4 @@ def read_xfails_from_yaml(filename: str) -> dict[str, str]:


def convert_main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Convert a xUnit testsuite report to e3-testsuite's"
" format."
)
parser.add_argument(
"--output",
"-o",
help="Output directory for the converted report. By default, use the"
" current working directory.",
)
parser.add_argument(
"--gaia-output",
action="store_true",
help="Output a GAIA-compatible testsuite report next to the YAML"
" report.",
)
parser.add_argument(
"--xfails",
help="YAML file that describes expected failures. If provided, it must"
" contain a mapping from test name to expected failure messages.",
)
parser.add_argument(
"xml-report",
nargs="+",
help="xUnit XML reports to convert. If a directory is passed,"
" recursively look for all the files matching *.xml that it contains.",
)

args = parser.parse_args(argv)
index = ReportIndex(args.output or ".")
mkdir(index.results_dir)
xfails = read_xfails_from_yaml(args.xfails) if args.xfails else None
importer = XUnitImporter(index, xfails)
for path in getattr(args, "xml-report"):
if os.path.isdir(path):
for root, _, filenames in os.walk(path):
for f in filenames:
importer.run(os.path.join(root, f))
else:
importer.run(path)
importer.warn_dangling_xfails()
index.write()

if args.gaia_output:
dump_gaia_report(index, index.results_dir)
XUnitImporterApp().run(argv)
131 changes: 130 additions & 1 deletion tests/tests/test_xunit.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the XUnit report feature."""

import os.path
import xml.etree.ElementTree as ET

import yaml
Expand All @@ -8,7 +9,11 @@
from e3.testsuite import Testsuite as Suite
from e3.testsuite.driver import TestDriver as Driver
from e3.testsuite.report.index import ReportIndex
from e3.testsuite.report.xunit import convert_main
from e3.testsuite.report.xunit import (
XUnitImporter,
XUnitImporterApp,
convert_main,
)
from e3.testsuite.result import TestStatus as Status

from .utils import create_testsuite, run_testsuite
Expand Down Expand Up @@ -476,3 +481,127 @@ def test_dangling_xfails(tmp_path, capsys):
assert not [
line for line in captured.err.splitlines() if "DEBUG" not in line
]


def test_app_override(tmp_path):
"""Test overriding in the XUnitImporterApp class."""
xml_report = str(tmp_path / "test.xml")
with open(xml_report, "w") as f:
f.write(
"""<?xml version="1.0" encoding="utf-8"?>
<testsuites name="TSList">
<testsuite name="ts">
<testcase name="tc1"></testcase>
<testcase name="tc2">
<failure>Some logging</failure>
</testcase>
</testsuite>
</testsuites>
"""
)

basic_results = {"ts.tc1": Status.PASS, "ts.tc2": Status.FAIL}

def check_results(results_dir, expected):
actual = {
e.test_name: e.status
for e in ReportIndex.read(results_dir).entries.values()
}
assert actual == expected

# Check overriding the add_basic_options method/output_dir property.

class CustomBasic(XUnitImporterApp):
def add_basic_options(self, parser):
parser.add_argument("e3-report-dir")

@property
def output_dir(self):
return getattr(self.args, "e3-report-dir")

def get_xfails(self):
return {}

def iter_xunit_files(self):
yield xml_report

@property
def gaia_report_requested(self):
return False

CustomBasic().run(["custom-basic"])
check_results("custom-basic", basic_results)

# Check overriding the create_output_report_index method

class CustomIndex(XUnitImporterApp):
def create_output_report_index(self):
mkdir("custom-index")
return ReportIndex("custom-index")

CustomIndex().run([xml_report])
check_results("custom-index", basic_results)

# Check overriding the add_options/get_xfails method

class CustomXFails(XUnitImporterApp):
def add_options(self, parser):
parser.add_argument("--add-xfail", action="append")

def get_xfails(self):
result = {}
for xfail in self.args.add_xfail:
test_name, msg = xfail.split(":", 1)
result[test_name] = msg
return result

CustomXFails().run(
[xml_report, "-o", "custom-xfails", "--add-xfail=ts.tc2:foo"]
)
check_results(
"custom-xfails", {"ts.tc1": Status.PASS, "ts.tc2": Status.XFAIL}
)

# Check overriding the create_importer method

class MyXUnitImporter(XUnitImporter):
def get_test_name(self, ts_name, tc_name, classname):
return tc_name

class CustomImporter(XUnitImporterApp):
def create_importer(self):
return MyXUnitImporter(self.index, self.xfails)

CustomImporter().run([xml_report, "-o", "custom-importer"])
check_results("custom-importer", {"tc1": Status.PASS, "tc2": Status.FAIL})

# Check overriding the iter_xunit_files method

class CustomIterXUnitFiles(XUnitImporterApp):
def iter_xunit_files(self):
yield xml_report

CustomIterXUnitFiles().run(["-o", "custom-iter-xunit-files", "foo.xml"])
check_results("custom-iter-xunit-files", basic_results)

# Check overriding the gaia_report_requested property

class CustomGAIA(XUnitImporterApp):
@property
def gaia_report_requested(self):
return True

CustomGAIA().run(["-o", "custom-gaia", xml_report])
check_results("custom-gaia", basic_results)
assert os.path.exists(os.path.join("custom-gaia", "results"))

# Check overriding the tear_down method

class CustomTearDown(XUnitImporterApp):
def tear_down(self):
with open(os.path.join(self.index.results_dir, "foo.txt"), "w"):
pass

CustomTearDown().run(["-o", "custom-tear-down", xml_report])
check_results("custom-tear-down", basic_results)
assert os.path.exists(os.path.join("custom-tear-down", "foo.txt"))

0 comments on commit 04f4862

Please sign in to comment.