pytest_metrics_xml_to_csv.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """
  2. Copyright (c) Contributors to the Open 3D Engine Project.
  3. For complete copyright and license terms please see the LICENSE at the root of this distribution.
  4. SPDX-License-Identifier: Apache-2.0 OR MIT
  5. Scrapes metrics from Pytest xml files and creates csv formatted files.
  6. """
  7. import os.path
  8. import sys
  9. import csv
  10. import argparse
  11. import xml.etree.ElementTree as xmlElementTree
  12. import datetime
  13. import uuid
  14. import ly_test_tools.cli.codeowners_hint as codeowners_hint
  15. from common import logging, exception
  16. # Setup logging.
  17. logger = logging.get_logger("test_metrics")
  18. logging.setup_logger(logger)
  19. # Create the csv field header
  20. PYTEST_FIELDS_HEADER = [
  21. 'test_name',
  22. 'status',
  23. 'duration_seconds',
  24. 'sig_owner'
  25. ]
  26. SIG_OWNER_CACHE = {}
  27. def _get_default_csv_filename():
  28. """
  29. Returns a default filename to be saved in the format of YYYY_MM_DDTHH_mm_SS_pytest.csv
  30. :return: The filename as a string
  31. """
  32. # Format default file name based off of date
  33. now = datetime.datetime.isoformat(datetime.datetime.now(tz=datetime.timezone.utc), timespec='seconds')
  34. return f"{now.replace('+00:00', 'Z').replace('-', '_').replace('.', '_').replace(':', '_')}_pytest.csv"
  35. def main():
  36. """
  37. Creates the folder structure for metrics to be saved to S3 and converts Pytest xml's into csv format. This script
  38. does not handle uploading of metrics.
  39. :return: None
  40. """
  41. # Parse args
  42. args = parse_args()
  43. if not os.path.exists(args.xml_folder):
  44. raise exception.MetricsExn(f"Cannot find directory: {args.xml_folder}")
  45. # Define directory format as branch/year/month/day/filename
  46. now = datetime.datetime.now(tz=datetime.timezone.utc)
  47. full_path = os.path.join(args.output_directory, args.branch, f"{now.year:04d}", f"{now.month:02d}", f"{now.day:02d}"
  48. , f"{str(uuid.uuid4())[:8]}.{args.csv_file}")
  49. if os.path.exists(full_path):
  50. logger.warning(f"The file {full_path} already exists. It will be overridden.")
  51. if not os.path.exists(os.path.dirname(full_path)):
  52. # Create directory if it doesn't exist
  53. os.makedirs(os.path.dirname(full_path))
  54. # Create csv file
  55. if os.path.exists(args.csv_file):
  56. logger.warning(f"The file {args.csv_file} already exists. It will be overriden.")
  57. with open(full_path, 'w', encoding='UTF8', newline='') as csv_file:
  58. writer = csv.DictWriter(csv_file, fieldnames=PYTEST_FIELDS_HEADER, restval='N/A')
  59. writer.writeheader()
  60. # Parse Pytest xml's and write to csv file
  61. for filename in os.listdir(args.xml_folder):
  62. if filename.endswith('.xml'):
  63. parse_pytest_xmls_to_csv(os.path.join(args.xml_folder, filename), writer)
  64. def parse_args():
  65. parser = argparse.ArgumentParser(
  66. description='This script assumes that Pytest xml files have been produced. The xml files will be parsed and '
  67. 'written to a csv file.')
  68. parser.add_argument(
  69. 'xml_folder',
  70. help="Path to where the Pytest xml files live. O3DE CTest defaults this to {build_folder}/Testing/Pytest"
  71. )
  72. parser.add_argument(
  73. "--csv-file", action="store", default=_get_default_csv_filename(),
  74. help=f"The file name for the csv to be saved. O3DE metrics pipeline will use default value."
  75. )
  76. parser.add_argument(
  77. "-o", "--output-directory", action="store", default=os.getcwd(),
  78. help=f"The directory where the csv to be saved. Prepends the --csv-file arg. O3DE metrics pipeline will use "
  79. f"default value."
  80. )
  81. parser.add_argument(
  82. "-b", "--branch", action="store", default="UnknownBranch",
  83. help="The branch the metrics were generated on. O3DE metrics pipeline requires the branch name to be specified."
  84. )
  85. args = parser.parse_args()
  86. return args
  87. def _determine_test_result(test_case_node):
  88. # type (xml.etree.ElementTree.Element) -> str
  89. """
  90. Inspects the test case node and determines the test result based on the presence of known element
  91. names such as 'error', 'failure' and 'skipped'. If the elements are not found, the test case is assumed
  92. to have passed. This is how the JUnit XML schema is generated for failed tests.
  93. :param test_case_node: The element node for the test case.
  94. :return: The test result
  95. """
  96. # Mapping from JUnit elements to test result to keep it consistent with CTest result reporting.
  97. failure_elements = [('error', 'failed'), ('failure', 'failed'), ('skipped', 'skipped')]
  98. for element in failure_elements:
  99. if test_case_node.find(element[0]) is not None:
  100. return element[1]
  101. return 'passed'
  102. def parse_pytest_xmls_to_csv(full_xml_path, writer):
  103. # type (str, DictWriter) -> None
  104. """
  105. Parses the PyTest xml file to write to a csv file. The structure of the PyTest xml is assumed to be as followed:
  106. <testcase classname="ExampleClass" file="SamplePath\test_Sample.py" line="43" name="test_Sample" time="113.225">
  107. <properties>
  108. <property name="test_case_id" value="IDVal"/>
  109. </properties>
  110. <system-out></system-out>
  111. </testcase>
  112. :param full_xml_path: The full path to the xml file
  113. :param writer: The DictWriter object to write to the csv file.
  114. :return: None
  115. """
  116. xml_root = xmlElementTree.parse(full_xml_path).getroot()
  117. test_data_dict = {}
  118. # Each PyTest test module will have a Test entry
  119. for test in xml_root.findall('./testsuite/testcase'):
  120. try:
  121. test_data_dict['test_name'] = test.attrib['name']
  122. test_data_dict['duration_seconds'] = float(test.attrib['time'])
  123. # using 'status' to keep it consistent with CTest xml schema
  124. test_data_dict['status'] = _determine_test_result(test)
  125. # replace slashes to match codeowners file
  126. test_file_path = test.attrib['file'].replace("\\", "/")
  127. except KeyError as exc:
  128. logger.exception(f"KeyError when parsing xml file: {full_xml_path}. Check xml keys for changes. Printing"
  129. f"attribs:\n{test.attrib}", exc)
  130. continue
  131. if test_file_path in SIG_OWNER_CACHE:
  132. sig_owner = SIG_OWNER_CACHE[test_file_path]
  133. else:
  134. # Index 1 is the sig owner
  135. _, sig_owner, _ = codeowners_hint.get_codeowners(test_file_path)
  136. SIG_OWNER_CACHE[test_file_path] = sig_owner
  137. test_data_dict['sig_owner'] = sig_owner if sig_owner else "N/A"
  138. writer.writerow(test_data_dict)
  139. if __name__ == "__main__":
  140. sys.exit(main())