From 9c7108e1dd779ecb6e2d8aad69a584fab0090759 Mon Sep 17 00:00:00 2001 From: Pierre-Marie de Rodat Date: Thu, 18 Jul 2024 15:14:08 +0200 Subject: [PATCH] Make it possible to customize e3-convert-xunit without code duplication --- NEWS.md | 3 + src/e3/testsuite/report/xunit.py | 178 +++++++++++++++++++++++-------- tests/tests/test_xunit.py | 131 ++++++++++++++++++++++- 3 files changed, 265 insertions(+), 47 deletions(-) diff --git a/NEWS.md b/NEWS.md index 3afa1f1..98d3972 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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. diff --git a/src/e3/testsuite/report/xunit.py b/src/e3/testsuite/report/xunit.py index 751d8f2..a4a52fb 100644 --- a/src/e3/testsuite/report/xunit.py +++ b/src/e3/testsuite/report/xunit.py @@ -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 @@ -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. @@ -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) diff --git a/tests/tests/test_xunit.py b/tests/tests/test_xunit.py index 502fe76..6876750 100644 --- a/tests/tests/test_xunit.py +++ b/tests/tests/test_xunit.py @@ -1,5 +1,6 @@ """Tests for the XUnit report feature.""" +import os.path import xml.etree.ElementTree as ET import yaml @@ -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 @@ -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( + """ + + + + + Some logging + + + + """ + ) + + 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"))