ctest_metrics_xml_to_csv.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  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 CTest 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. from common import logging
  15. DEFAULT_CTEST_LOG_FILENAME = 'Test.xml'
  16. TAG_FILE = 'TAG'
  17. TESTING_DIR = 'Testing'
  18. # Setup logging.
  19. logger = logging.get_logger("test_metrics")
  20. logging.setup_logger(logger)
  21. # Create the csv field header
  22. CTEST_FIELDS_HEADER = [
  23. 'test_name',
  24. 'status',
  25. 'duration_seconds'
  26. ]
  27. def _get_default_csv_filename():
  28. # Format default file name based off of date
  29. now = datetime.datetime.isoformat(datetime.datetime.now(tz=datetime.timezone.utc), timespec='seconds')
  30. return f"{now.replace('+00:00', 'Z').replace('-', '_').replace('.', '_').replace(':', '_')}.csv"
  31. def main():
  32. # Parse args
  33. args = parse_args()
  34. # Construct the full path to the xml file
  35. xml_file_path = _get_test_xml_path(args.build_folder, args.ctest_log)
  36. # Define directory format as branch/year/month/day/filename
  37. now = datetime.datetime.now(tz=datetime.timezone.utc)
  38. full_path = os.path.join(args.output_directory, args.branch, f"{now.year:04d}", f"{now.month:02d}", f"{now.day:02d}"
  39. , f"{str(uuid.uuid4())[:8]}.{args.csv_file}")
  40. if os.path.exists(full_path):
  41. logger.warning(f"The file {full_path} already exists. It will be overridden.")
  42. if not os.path.exists(os.path.dirname(full_path)):
  43. # Create directory if it doesn't exist
  44. os.makedirs(os.path.dirname(full_path))
  45. with open(full_path, 'w', encoding='UTF8', newline='') as csv_file:
  46. writer = csv.DictWriter(csv_file, fieldnames=CTEST_FIELDS_HEADER, restval='N/A')
  47. writer.writeheader()
  48. # Parse CTest xml and write to csv file
  49. parse_ctest_xml_to_csv(xml_file_path, writer)
  50. def parse_args():
  51. parser = argparse.ArgumentParser(
  52. description='This script assumes that a CTest xml file has been produced via the -T Test CTest option. The file'
  53. 'should exist inside of the build directory. The xml file will be parsed and write to a csv file.')
  54. parser.add_argument(
  55. 'build_folder',
  56. help="Path to a CMake build folder (generated by running cmake)."
  57. )
  58. parser.add_argument(
  59. "-cl", "--ctest-log", action="store", default=DEFAULT_CTEST_LOG_FILENAME,
  60. help=f"The file name for the CTest output log (defaults to '{DEFAULT_CTEST_LOG_FILENAME}').",
  61. )
  62. parser.add_argument(
  63. "--csv-file", action="store", default=_get_default_csv_filename(),
  64. help=f"The directory and file name for the csv to be saved."
  65. )
  66. parser.add_argument(
  67. "-o", "--output-directory", action="store", default=os.getcwd(),
  68. help=f"The directory where the csv to be saved. Prepends the --csv-file arg."
  69. )
  70. parser.add_argument(
  71. "-b", "--branch", action="store", default="UnknownBranch",
  72. help="The branch the metrics were generated on. Used for directory saving."
  73. )
  74. args = parser.parse_args()
  75. return args
  76. def _get_test_xml_path(build_path, xml_file):
  77. # type: (str, str) -> str
  78. """
  79. Retrieves the full path to the CTest xml file. The xml file is produced in a folder that is defined by timestamp.
  80. This timestamp is defined as the first line in the CTest TAG file. The files are assumed to be created by CTest in
  81. the <build_path>//Testing directory.
  82. :param build_path: The full path to the cmake build folder
  83. :param xml_file: The name of the xml file
  84. :return: The full path to the xml file
  85. """
  86. full_tag_path = os.path.join(build_path, TESTING_DIR, TAG_FILE)
  87. if not os.path.exists(full_tag_path):
  88. raise FileNotFoundError(f"Could not find CTest TAG file at: {full_tag_path}")
  89. with open(full_tag_path, 'r') as tag_file:
  90. # The first line of the TAG file is the name of the folder
  91. line = tag_file.readline()
  92. if not line:
  93. raise EOFError("The CTest TAG file did not contain the name of the xml folder")
  94. folder_name = line.strip()
  95. xml_full_path = os.path.join(build_path, TESTING_DIR, folder_name, xml_file)
  96. if not os.path.exists(xml_full_path):
  97. raise FileNotFoundError(f'Unable to find CTest output log at: {xml_full_path}.')
  98. return xml_full_path
  99. def parse_ctest_xml_to_csv(full_xml_path, writer):
  100. # type (str, DictWriter) -> None
  101. """
  102. Parses the CTest xml file and writes the data to a csv file. Each test result will be written as a separate line.
  103. The structure of the CTest xml is assumed to be as followed:
  104. <?xml version="1.0" encoding="UTF-8"?>
  105. <Site>
  106. <Testing>
  107. <Test Status=...>
  108. <Name>...</Name>
  109. <Path>...</Path>
  110. <FullName>...</FullName>
  111. <FullCommandLine>...</FullCommandLine>
  112. <Results>
  113. <NamedMeasurement type="numeric/double" name="Execution Time">
  114. <Value>...</Value>
  115. </NamedMeasurement>
  116. <NamedMeasurement type="numeric/double" name="Processors">
  117. <Value>...</Value>
  118. </NamedMeasurement>
  119. <NamedMeasurement type="text/string" name="Completion Status">
  120. <Value>...</Value>
  121. </NamedMeasurement>
  122. <NamedMeasurement type="text/string" name="Command Line">
  123. <Value>...</Value>
  124. </NamedMeasurement>
  125. <Measurement>
  126. <Value encoding="base64" compression="gzip"...</Value>
  127. </Measurement>
  128. </Results>
  129. <Labels>
  130. <Label>SUITE_smoke</Label>
  131. <Label>COMPONENT_foo</Label>
  132. <Label>...</Label>
  133. </Labels>
  134. </Test>
  135. <Test Status="passed">
  136. ...
  137. </Test>
  138. </Testing>
  139. </Site>
  140. :param full_xml_path: The full path to the xml file
  141. :param writer: The DictWriter object to write to the csv file.
  142. :return: None
  143. """
  144. xml_root = xmlElementTree.parse(full_xml_path).getroot()
  145. if not os.path.exists(full_xml_path):
  146. logger.warning(f"XML file not found at: {full_xml_path}. Script has nothing to convert.")
  147. return
  148. # Each CTest test module will have a Test entry
  149. try:
  150. for test in xml_root.findall('./Testing/Test'):
  151. test_data_dict = {}
  152. # Get test execution time
  153. test_time = 0
  154. # There are many NamedMeasurements, but we need the one for Execution Time
  155. for measurement in test.findall('Results/NamedMeasurement'):
  156. if measurement.attrib['name'] == 'Execution Time':
  157. test_time = float(measurement.find('Value').text)
  158. # Create a dict/json format to write to csv file
  159. test_data_dict['test_name'] = test.find('Name').text
  160. test_data_dict['status'] = test.attrib['Status']
  161. test_data_dict['duration_seconds'] = test_time
  162. writer.writerow(test_data_dict)
  163. except KeyError as exc:
  164. logger.exception(f"KeyError when parsing xml file: {full_xml_path}. Check xml keys for changes.", exc)
  165. if __name__ == "__main__":
  166. sys.exit(main())