artifact_manager.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  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. Workspace Manager: Provides an API for managing lumberyard installations and file manipulation
  6. """
  7. import logging
  8. import os
  9. import shutil
  10. import six
  11. import stat
  12. import tempfile
  13. import ly_test_tools.environment.file_system as file_system
  14. logger = logging.getLogger(__name__)
  15. class ArtifactManager(object):
  16. def __init__(self, root):
  17. self.artifact_path = root # i.e.: ~/dev/TestResults/2019-10-15T13_38_42_855000/
  18. self.dest_path = None
  19. self.set_dest_path() # Sets the self.dest_path attribute as the main artifact save path for test files.
  20. def _get_collision_handled_filename(self, file_path, amount=1):
  21. # type: (str, int) -> str
  22. """
  23. Handles filename collision by appending integers, checking if the file exists, then incrementing if so. Will
  24. increase up to the amount parameter before stopping.
  25. :param file_path: The file path as a string to check for name collisions
  26. :param amount: amount of renames possible for the string if file_path collision occurs by appending integers to
  27. the name. If the amount is reached, the save will override the file instead.
  28. :return: The new file_path as a string
  29. """
  30. # Name collision handling not needed for 1 name
  31. if amount == 1:
  32. return file_path
  33. # extension will be an empty string if it doesn't exist
  34. file_without_ext, extension = os.path.splitext(file_path)
  35. for i in range(1, amount): # Start at "_1" instead of "_0"
  36. updated_path = f"{file_without_ext}_{i}{extension}"
  37. if not os.path.exists(updated_path):
  38. return updated_path
  39. logger.warning(f"Maximum number of attempts: {amount} met when trying to handle name collision for file: "
  40. f"{file_path}. Ending on {updated_path} which will override the existing file.")
  41. return updated_path
  42. def set_dest_path(self, test_name=None, amount=1):
  43. """
  44. Sets self.dest_path if not set, and returns the value currently set in self.dest_path. Also creates the
  45. directory if it already doesn't exist.
  46. :param test_name: Set the test name used to log the artifacts, format of "module_class_method"
  47. i.e. "test_module_TestClass_test_BasicTestMethod_ValidInputTest_ReturnsTrue_1"
  48. If set, all artifacts are saved to a subdir named after this test. This value will get appended to the main
  49. path in the self.dest_path attribute.
  50. :param amount: The amount of folders to create matching self.dest_path and adding an index value to each.
  51. :return: None, but sets the self.dest_path attribute when called.
  52. """
  53. if test_name:
  54. self.dest_path = os.path.join(self.dest_path, file_system.sanitize_file_name(test_name))
  55. elif not self.dest_path:
  56. self.dest_path = self.artifact_path
  57. # Create unique artifact folder
  58. if not os.path.exists(self.dest_path):
  59. self.dest_path = self._get_collision_handled_filename(self.dest_path, amount)
  60. try:
  61. logger.debug(f'Attempting to create new artifact path: "{self.dest_path}"')
  62. if not os.path.exists(self.dest_path):
  63. os.makedirs(self.dest_path, exist_ok=True)
  64. logger.info(f'Created new artifact path: "{self.dest_path}"')
  65. return self.dest_path
  66. except (IOError, OSError, WindowsError) as err:
  67. problem = WindowsError(f'Failed to create new artifact path: "{self.dest_path}"')
  68. six.raise_from(problem, err)
  69. def generate_folder_name(self, test_module, test_class, test_method):
  70. """
  71. Takes a test module, class, & method and generates a folder name string with an added
  72. count value to make the name unique for test methods that run multiple times.
  73. Returns the newly generated folder name string.
  74. :param test_module: string for the name of the current test module
  75. :param test_class: string for the name of the current test class
  76. :param test_method: string for the name of the current test method
  77. :return: string for naming a folder that represents the current test, with a maximum length
  78. of 60 trimmed down to match this requirement.
  79. """
  80. folder_name = file_system.reduce_file_name_length(
  81. file_name="{}_{}_{}".format(test_module, test_class, test_method),
  82. max_length=60)
  83. return folder_name
  84. def save_artifact(self, artifact_path, artifact_name=None, amount=1):
  85. """
  86. Store an artifact to be logged. Will ensure the new artifact is writable to prevent directory from being
  87. locked later.
  88. :param artifact_path: string representing the full path to the artifact folder.
  89. :param artifact_name: string representing a new artifact name for log if necessary, max length: 25 characters.
  90. :param amount: amount of renames possible for the saved artifact if file name collision occurs by appending
  91. integers to the name. If the amount is reached, the save will override the file instead.
  92. :return destination_path: a destination folder if a folder is copied, a destination file path if a file is copied
  93. """
  94. if artifact_name:
  95. artifact_name = file_system.reduce_file_name_length(file_name=artifact_name, max_length=25)
  96. dest_path = os.path.join(self.dest_path,
  97. artifact_name if artifact_name is not None else os.path.basename(artifact_path))
  98. if os.path.exists(dest_path):
  99. dest_path = self._get_collision_handled_filename(dest_path, amount)
  100. logger.debug("Copying artifact from '{}' to '{}'".format(artifact_path, dest_path))
  101. dest_folder, _ = os.path.split(dest_path)
  102. try:
  103. if not os.path.exists(dest_folder):
  104. os.makedirs(dest_folder, exist_ok=True)
  105. if os.path.isdir(artifact_path):
  106. shutil.copytree(artifact_path, dest_path, dirs_exist_ok=True)
  107. else:
  108. shutil.copy(artifact_path, dest_path)
  109. os.chmod(dest_path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
  110. except FileExistsError as exc:
  111. logger.exception(f"Error occurred while trying to save {dest_path}. Printing contents of folder:\n"
  112. f"{os.listdir(dest_folder)}\n")
  113. raise exc
  114. return dest_path
  115. def generate_artifact_file_name(self, artifact_name):
  116. """
  117. Returns a string for generating a new artifact file inside of the artifact folder.
  118. :param artifact_name: string representing the name for the artifact file (i.e. "ToolsInfo.log")
  119. :return: string for the full path to the file inside the artifact path even if not valid, i.e.:
  120. ~/dev/TestResults/2019-10-14T11_36_12_234000/pytest_results/test_module_TestClass_Method_1/ToolsInfo.log
  121. """
  122. if not artifact_name:
  123. raise ValueError(f'artifact_name is a required parameter. Actual: {artifact_name}')
  124. return os.path.join(self.dest_path, artifact_name)
  125. def gather_artifacts(self, destination, format='zip'):
  126. """
  127. Gather collected artifacts to the specified destination as an archive file (zip by default).
  128. Destination should not contain file extension, the second parameter automatically determines the best extension
  129. to use.
  130. :param destination: where to write the archive file, do not add extension
  131. :param format: archive format, default is 'zip', possible values: tar, gztar and bztar.
  132. :return: full path to the generated archive or raises a WindowsError if shutil.mark_archive() fails.
  133. """
  134. try:
  135. return shutil.make_archive(destination, format, self.dest_path)
  136. except WindowsError:
  137. logger.exception(
  138. f'Windows failed to find the target artifact path: "{self.dest_path}" '
  139. 'which may indicate test setup failed.')
  140. class NullArtifactManager(ArtifactManager):
  141. """
  142. An ArtifactManager that ignores all calls, used when logging is not configured.
  143. """
  144. def __init__(self):
  145. # The null ArtifactManager redirects all calls to a temp dir
  146. super(NullArtifactManager, self).__init__(tempfile.mkdtemp())
  147. def _get_collision_handled_filename(self, artifact_path=None, amount=None):
  148. raise NotImplementedError("Attempt was made to create artifact save paths through NullArtifactManager.")
  149. def save_artifact(self, artifact, artifact_name=None, amount=None):
  150. return None
  151. def gather_artifacts(self, destination, format='zip'):
  152. return None