1055 lines
46 KiB
Python
1055 lines
46 KiB
Python
"""
|
|
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
|
|
its licensors.
|
|
|
|
For complete copyright and license terms please see the LICENSE at the root of this
|
|
distribution (the "License"). All use of this software is governed by the License,
|
|
or, if provided, by the license below or the license accompanying this file. Do not
|
|
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
A class to control functionality of Lumberyard's asset processor.
|
|
The class manages a workspace's asset processor and asset configurations.
|
|
"""
|
|
import os
|
|
import datetime
|
|
import logging
|
|
import subprocess
|
|
import socket
|
|
import time
|
|
import tempfile
|
|
import shutil
|
|
import stat
|
|
from typing import List
|
|
import psutil
|
|
|
|
import ly_test_tools
|
|
import ly_test_tools.environment.waiter as waiter
|
|
import ly_test_tools.environment.file_system as file_system
|
|
import ly_test_tools.o3de.pipeline_utils as utils
|
|
from ly_test_tools.o3de.ap_log_parser import APLogParser
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
POLLING_INTERVAL_SEC = 0.5
|
|
DEFAULT_TIMEOUT_HOURS = 8
|
|
DEFAULT_TIMEOUT_SECONDS = 300
|
|
|
|
ASSET_PROCESSOR_PLATFORM_MAP = {
|
|
'android': 'es3',
|
|
'ios': 'ios',
|
|
'linux': 'linux', # Not fully implemented, see SPEC-2501
|
|
'mac': 'osx_gl',
|
|
'windows': 'pc',
|
|
}
|
|
|
|
ASSET_PROCESSOR_SETTINGS_ROOT_KEY = '/Amazon/AssetProcessor/Settings'
|
|
|
|
class AssetProcessorError(Exception):
|
|
""" Indicates that the AssetProcessor raised an error """
|
|
|
|
|
|
class AssetProcessor(object):
|
|
def __init__(self, workspace, port=45643):
|
|
# type: (AbstractWorkspaceManager, int) -> AssetProcessor
|
|
"""
|
|
The AssetProcessor requires a workspace to set the paths and platforms.
|
|
|
|
:param workspace: The WorkspaceManager where the AP lives
|
|
:param port: The port for the AP to use
|
|
:return: None
|
|
"""
|
|
self._workspace = workspace
|
|
self._port = port
|
|
self._ap_proc = None
|
|
self._temp_asset_directory = None
|
|
self._temp_asset_root = None
|
|
self._project_path = self._workspace.paths.project()
|
|
self._override_scan_folders = []
|
|
self._test_assets_source_folder = None
|
|
self._cache_folder = None
|
|
self._assets_source_folder = None
|
|
self._control_connection = None
|
|
self._function_name = None
|
|
self._failed_log_root = None
|
|
self._temp_log_directory = None
|
|
self._temp_log_root = None
|
|
self._disable_all_platforms = False
|
|
self._enabled_platform_overrides = dict()
|
|
|
|
# Starts AP but does not by default run until idle.
|
|
def start(self, connection_timeout=30, quitonidle=False, add_gem_scan_folders=None, add_config_scan_folders=None,
|
|
accept_input=True, run_until_idle=False, scan_folder_pattern=None, connect_to_ap=False):
|
|
self.gui_process(quitonidle=quitonidle, add_gem_scan_folders=add_gem_scan_folders,
|
|
add_config_scan_folders=add_config_scan_folders, timeout=connection_timeout,
|
|
connect_to_ap=connect_to_ap, accept_input=accept_input, run_until_idle=run_until_idle,
|
|
scan_folder_pattern=scan_folder_pattern)
|
|
|
|
def wait_for_idle(self, timeout=DEFAULT_TIMEOUT_SECONDS):
|
|
"""
|
|
Communicate with asset processor to request a response if either AP is currently idle,
|
|
or whenever it becomes idle
|
|
"""
|
|
self.send_message("waitforidle")
|
|
result = self.read_message(read_timeout=timeout)
|
|
assert result == "idle" or not self.process_exists(), f"Couldn't get idle state from AP, message {result}"
|
|
return True
|
|
|
|
def next_idle(self):
|
|
"""
|
|
Communicate with asset processor to request AP respond the next time it becomes idle. This method
|
|
does NOT return immediately if AP is currently idle, rather it assumes there is work about to be
|
|
done and no response is desired until it is completed. This is useful when making source file changes
|
|
which will be picked up by the scanner but may take a couple seconds to begin, and you only want
|
|
to hear back after it's done.
|
|
"""
|
|
self.send_message("signalidle")
|
|
result = self.read_message()
|
|
assert result == "idle" or not self.process_exists(), f"Couldn't get idle state from AP, message {result}"
|
|
|
|
def send_quit(self):
|
|
"""
|
|
Send the quit command request to AP over the control channel
|
|
"""
|
|
return self.send_message("quit")
|
|
|
|
def send_message(self, message):
|
|
"""
|
|
Communicate with asset processor. Must be running through gui_process/start method
|
|
:param message: message to send
|
|
"""
|
|
if not self._ap_proc:
|
|
logger.warning("Attempted to send message to AP but not currently running")
|
|
return False
|
|
|
|
if not self._control_connection:
|
|
self.connect_control()
|
|
|
|
if not self._control_connection:
|
|
return False
|
|
|
|
try:
|
|
self._control_connection.sendall(message.encode())
|
|
logger.info(f"Sent input {message}")
|
|
return True
|
|
except IOError as e:
|
|
logger.warning(f"Failed to send message {message} to AP with error {e}")
|
|
return False
|
|
|
|
def read_message(self, read_timeout=DEFAULT_TIMEOUT_SECONDS):
|
|
"""
|
|
Read the next message from the AP Control socket. Must be running through gui_process/start method
|
|
"""
|
|
if not self._ap_proc:
|
|
logger.warning("Attempted to read message to AP but not currently running")
|
|
return "no_process"
|
|
|
|
if not self._control_connection:
|
|
self.connect_control()
|
|
|
|
if not self._control_connection:
|
|
return "no_connection"
|
|
|
|
self._control_connection.settimeout(read_timeout)
|
|
try:
|
|
result = self._control_connection.recv(4096)
|
|
result_message = result.decode()
|
|
logger.info(f"Got result message {result_message}")
|
|
return result_message
|
|
except IOError as e:
|
|
logger.warning(f"Failed to read message from with error {e}")
|
|
return f"error_{e}"
|
|
|
|
|
|
|
|
def read_control_port(self):
|
|
return self.read_port_from_log("Control Port")
|
|
|
|
def read_listening_port(self):
|
|
return self.read_port_from_log("Listening Port")
|
|
|
|
def read_port_from_log(self, port_type):
|
|
"""
|
|
Read the a port chosen by AP from the log
|
|
"""
|
|
start_time = time.time()
|
|
read_port_timeout = 10
|
|
while (time.time() - start_time) < read_port_timeout:
|
|
if not os.path.exists(self._workspace.paths.ap_gui_log()):
|
|
logger.debug(f"Log at {self._workspace.paths.ap_gui_log()} doesn't exist, sleeping")
|
|
else:
|
|
log = APLogParser(self._workspace.paths.ap_gui_log())
|
|
if len(log.runs):
|
|
try:
|
|
port = log.runs[-1][port_type]
|
|
if port:
|
|
logger.info(f"Read port type {port_type} : {port}")
|
|
return port
|
|
except:
|
|
pass
|
|
time.sleep(1)
|
|
logger.warning(f"Failed to read port type {port_type}")
|
|
return 0
|
|
|
|
def set_control_connection(self, connection):
|
|
self._control_connection = connection
|
|
|
|
def connect_control(self):
|
|
"""
|
|
Set up the control socket connection with asset processor. The control connection is to allow for basic
|
|
two way communication with AP for things such as querying state and making basic requests such as shutdown.
|
|
"""
|
|
if not self._control_connection:
|
|
control_timeout = 60
|
|
try:
|
|
return self.connect_socket("Control Connection", self.read_control_port,
|
|
set_port_method=self.set_control_connection, timeout=control_timeout)
|
|
except AssetProcessorError as e:
|
|
# We dont want a failure of our test socket connection to fail the entire test automatically.
|
|
logger.error(f"Failed to connect control socket with error {e}")
|
|
pass
|
|
return True, None
|
|
|
|
def using_temp_workspace(self):
|
|
return self._temp_asset_root is not None
|
|
|
|
def connect_socket(self, port_name, read_port_method, host='127.0.0.1', set_port_method=None,
|
|
timeout=10):
|
|
"""
|
|
:param port_name: Str defining the name of the port for logging
|
|
:param read_port_method: Method to retrieve the port number to use
|
|
:param host: Host ip to connect to
|
|
:param set_port_method: If set, method to call with the established connection
|
|
:param timeout: Max seconds to attempt connection for
|
|
"""
|
|
|
|
connection_timeout = timeout
|
|
connect_port = read_port_method()
|
|
logger.debug(f"Waiting for connection to AP {port_name}: {host}:{connect_port}, "
|
|
f"{connection_timeout} seconds remaining")
|
|
start_time = time.time()
|
|
while (time.time() - start_time) < connection_timeout:
|
|
connection_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
connection_socket.settimeout(10.0)
|
|
try:
|
|
connection_socket.connect((host, connect_port))
|
|
logger.debug(f"Connection to AP {port_name} was successful")
|
|
if set_port_method is not None:
|
|
set_port_method(connection_socket)
|
|
return True, None
|
|
except Exception as e: # Purposefully broad
|
|
# Short delay to prevent immediate failure due to slower starting applications such as debug builds
|
|
time.sleep(0.01)
|
|
if not connect_port or not self.using_temp_workspace():
|
|
# If we're not using a temp workspace with a fresh log it's possible we're reading a port from
|
|
# a previous run and the log just hasn't written yet, we need to keep checking the log for a new
|
|
# port to use
|
|
new_connect_port = read_port_method()
|
|
if new_connect_port != connect_port:
|
|
logger.debug(
|
|
f"Read new connect port for {port_name}: {host}:{new_connect_port}")
|
|
connect_port = new_connect_port
|
|
raise AssetProcessorError(f"Could not connect to AP {port_name}")
|
|
|
|
def stop(self, timeout=60):
|
|
"""
|
|
Stops the AssetProcessor. First attempts a clean shutdown over the control channel
|
|
if open. Calls terminate if no control connection is open
|
|
:param timeout: How long to wait, in seconds, for AP to shut down after receiving quit message.
|
|
This timeout is a longer default because AP doesn't quit immediately on receiving the quit message,
|
|
it waits until finishing its current task, which can sometimes take a while.
|
|
:return: None
|
|
"""
|
|
if not self._ap_proc:
|
|
logger.info("Attempting to quit AP but none running")
|
|
return
|
|
|
|
if not self._control_connection:
|
|
logger.info("No control connection open, using terminate")
|
|
return self.terminate()
|
|
|
|
try:
|
|
if not self.send_quit():
|
|
logger.warning("Failed to send quit command, using terminate")
|
|
self.terminate()
|
|
except IOError as e:
|
|
logger.warning(f"Failed to send quit request with error {e}, stopping")
|
|
self.terminate()
|
|
wait_timeout = timeout
|
|
|
|
try:
|
|
waiter.wait_for(lambda: not self.process_exists(), exc=AssetProcessorError, timeout=wait_timeout)
|
|
except AssetProcessorError:
|
|
logger.warning(f"Timeout attempting to quit asset processor after {wait_timeout} seconds, using terminate")
|
|
self.terminate()
|
|
pass
|
|
|
|
if self.process_exists():
|
|
logger.warning(f"Failed to stop process {self.get_pid()} after {wait_timeout} seconds, using terminate")
|
|
self.terminate()
|
|
self._ap_proc = None
|
|
|
|
def terminate(self):
|
|
"""
|
|
Forcibly stops AP and child processes
|
|
:return: None
|
|
"""
|
|
|
|
process_list = self.get_process_list()
|
|
# An Asset Processor process can be running but if _ap_proc is None, it means we don't own it.
|
|
# If we don't own it, we won't kill it
|
|
if len(process_list) == 0:
|
|
logger.info("Attempted to stop asset processor, but it's already closed.")
|
|
return
|
|
process_list.reverse() # Kill child processes first
|
|
|
|
if self._control_connection:
|
|
self._control_connection.close()
|
|
self._control_connection = None
|
|
|
|
def term_success(proc):
|
|
logger.info(f"Successfully terminated {proc} with code {proc.returncode}")
|
|
|
|
for this_process in process_list:
|
|
logger.info(f"Terminating: {this_process.name()} pid: {this_process.pid}")
|
|
try:
|
|
this_process.terminate()
|
|
except psutil.NoSuchProcess:
|
|
# Already terminated
|
|
pass
|
|
_, remaining = psutil.wait_procs(process_list, timeout=10, callback=term_success)
|
|
for process in remaining:
|
|
logger.info(f"Killing: {this_process.name()} pid: {this_process.pid}")
|
|
process.kill()
|
|
logger.info("Finished terminating asset processor")
|
|
self._ap_proc = None
|
|
|
|
def get_pid(self):
|
|
"""
|
|
Returns pid of asset processor proc currently executing the start command (Ap Gui)
|
|
|
|
:return: pid if exists, else -1
|
|
"""
|
|
return self._ap_proc.pid if self._ap_proc else -1
|
|
|
|
def get_process_list(self, name_filter: str = None):
|
|
"""
|
|
Returns a list of all processes currently executing under an AP start run, such as AP Gui and Builders
|
|
|
|
:param name_filter: Name to match against, such as AssetBuilder
|
|
:return: List of processes
|
|
"""
|
|
my_pid = self.get_pid()
|
|
if my_pid is -1:
|
|
return []
|
|
return_list = []
|
|
try:
|
|
return_list = [psutil.Process(my_pid)]
|
|
return_list.extend(utils.child_process_list(my_pid))
|
|
except psutil.NoSuchProcess:
|
|
logger.warning("Process already finished calling get_process_list")
|
|
return return_list
|
|
|
|
def process_cpu_usage_below(self, cpu_usage_threshold: float) -> bool:
|
|
"""
|
|
Checks whether CPU usage by AP and any child processes falls below a threshold
|
|
|
|
:param cpu_usage_threshold: Float at or above which CPU usage by a process instance is too high
|
|
:return: True if the CPU usage for each instance of the specified process is below the threshold, False if not
|
|
"""
|
|
# Get all instances of targeted process
|
|
targeted_processes = self.get_process_list()
|
|
|
|
# Return whether all instances of targeted process are idle
|
|
for targeted_process in targeted_processes:
|
|
logger.info(f"Process name: {targeted_process.name()}")
|
|
if hasattr(targeted_process, "pid"):
|
|
logger.info(f"Process ID: {targeted_process.pid}")
|
|
process_cpu_load = targeted_process.cpu_percent(interval=1)
|
|
logger.info(f"Process CPU load: {process_cpu_load}")
|
|
if process_cpu_load >= cpu_usage_threshold:
|
|
return False
|
|
return True
|
|
|
|
def backup_ap_settings(self):
|
|
# type: () -> None
|
|
"""
|
|
Backs up the asset processor settings file via the LySettings class
|
|
|
|
:return: None
|
|
"""
|
|
backup_path = self._workspace.settings.get_temp_path()
|
|
logger.debug(f"Performing backup of asset processor in path {backup_path}")
|
|
self._workspace.settings.backup_asset_processor_settings(backup_path)
|
|
|
|
def restore_ap_settings(self):
|
|
# type: () -> None
|
|
"""
|
|
Restores the asset processor settings file via the LySettings class
|
|
|
|
:return: None
|
|
"""
|
|
backup_path = self._workspace.settings.get_temp_path()
|
|
logger.debug(f"Restoring backup of asset processor in path {backup_path}")
|
|
self._workspace.settings.restore_asset_processor_settings(backup_path)
|
|
|
|
def teardown(self):
|
|
# type: () -> None
|
|
"""
|
|
Restores backup of asset processor settings and stops the asset processor
|
|
|
|
:return: None
|
|
"""
|
|
self.stop()
|
|
self.restore_ap_settings()
|
|
|
|
def process_exists(self):
|
|
try:
|
|
my_pid = self.get_pid()
|
|
if my_pid == -1:
|
|
return False
|
|
return psutil.pid_exists(my_pid)
|
|
except psutil.NoSuchProcess:
|
|
pass
|
|
return False
|
|
|
|
def batch_process(self, timeout=DEFAULT_TIMEOUT_SECONDS, fastscan=True, capture_output=False, platforms=None,
|
|
extra_params=None, add_gem_scan_folders=None, add_config_scan_folders=None, decode=True,
|
|
expect_failure=False, scan_folder_pattern=None):
|
|
self.create_temp_log_root()
|
|
ap_path = self._workspace.paths.asset_processor_batch()
|
|
command = self.build_ap_command(ap_path=ap_path, fastscan=fastscan, platforms=platforms,
|
|
extra_params=extra_params, add_gem_scan_folders=add_gem_scan_folders,
|
|
add_config_scan_folders=add_config_scan_folders,
|
|
scan_folder_pattern=scan_folder_pattern)
|
|
return self.run_ap_process_command(command, timeout=timeout, capture_output=capture_output, decode=decode,
|
|
expect_failure=expect_failure)
|
|
|
|
# Start AP gui and run until idle. Useful for asset processing tests where the assets should be seen and processed
|
|
# by AP on startup and in the test you do not wish to continue execution until you know AP has gone idle.
|
|
def gui_process(self, timeout=DEFAULT_TIMEOUT_SECONDS, fastscan=True, capture_output=False, platforms=None,
|
|
extra_params=None, add_gem_scan_folders=None, add_config_scan_folders=None, decode=True,
|
|
expect_failure=False, quitonidle=False, connect_to_ap=False, accept_input=True, run_until_idle=True,
|
|
scan_folder_pattern=None):
|
|
ap_path = self._workspace.paths.asset_processor()
|
|
extra_gui_params = []
|
|
if quitonidle:
|
|
extra_gui_params.append("--quitonidle")
|
|
if accept_input:
|
|
extra_gui_params.append("--acceptInput")
|
|
|
|
ap_exe_path = os.path.dirname(self._workspace.paths.asset_processor())
|
|
|
|
logger.info("Starting asset processor")
|
|
if self.process_exists():
|
|
logger.error("Asset processor already started. Stop first")
|
|
return False, None
|
|
|
|
if not platforms:
|
|
if hasattr(self._workspace, "asset_processor_platform"):
|
|
ap_target = self._workspace.asset_processor_platform
|
|
else:
|
|
ap_target = ly_test_tools.HOST_OS_PLATFORM
|
|
ap_platform = ASSET_PROCESSOR_PLATFORM_MAP.get(ap_target, ly_test_tools.HOST_OS_PLATFORM)
|
|
logger.info(f"Setting AP platform to: {ap_platform}")
|
|
extra_gui_params.append('--platforms')
|
|
extra_gui_params.append(ap_platform)
|
|
|
|
if extra_params:
|
|
if isinstance(extra_params, list):
|
|
extra_gui_params.extend(extra_params)
|
|
else:
|
|
extra_gui_params.append(extra_params)
|
|
|
|
self.create_temp_log_root()
|
|
command = self.build_ap_command(ap_path=ap_path, fastscan=fastscan, platforms=platforms,
|
|
extra_params=extra_gui_params, add_gem_scan_folders=add_gem_scan_folders,
|
|
add_config_scan_folders=add_config_scan_folders,
|
|
scan_folder_pattern=scan_folder_pattern)
|
|
|
|
# If the AP is quitting on idle just run it like AP batch.
|
|
if quitonidle:
|
|
return self.run_ap_process_command(command, timeout=timeout, capture_output=capture_output, decode=decode,
|
|
expect_failure=expect_failure)
|
|
|
|
logger.info(f"Launching AP at path: {self._workspace.paths.asset_processor()}")
|
|
|
|
if capture_output:
|
|
logger.warning(f"Cannot capture output when leaving AP connection open.")
|
|
|
|
logger.info(f"Launching AP with command: {command}")
|
|
self._ap_proc = subprocess.Popen(command, cwd=ap_exe_path)
|
|
|
|
if accept_input and not quitonidle:
|
|
self.connect_control()
|
|
|
|
if connect_to_ap:
|
|
self.connect_listen()
|
|
|
|
if quitonidle:
|
|
waiter.wait_for(lambda: not self.process_exists(), timeout=timeout)
|
|
elif run_until_idle and accept_input:
|
|
if not self.wait_for_idle():
|
|
return False, None
|
|
return True, None
|
|
|
|
def connect_listen(self, timeout=DEFAULT_TIMEOUT_SECONDS):
|
|
# Wait for the AP we launched to be ready to accept a connection
|
|
return self.connect_socket("Listen Connection", self.read_listening_port, timeout=timeout)
|
|
|
|
def build_ap_command(self, ap_path, fastscan=True, platforms=None,
|
|
extra_params=None, add_gem_scan_folders=None, add_config_scan_folders=None,
|
|
scan_folder_pattern=None):
|
|
# type: (float, bool) -> bool
|
|
"""
|
|
Launch asset processor batch and wait for it to complete or until the timeout expires.
|
|
Returns true on success and False if an error is reported.
|
|
|
|
:param fastscan: Enable "zero analysis mode"
|
|
:param capture_output = Capture output which will be returned in the second of the return pair
|
|
:param platforms: Different set of platforms to run against
|
|
:param add_gem_scan_folders: Should gem scan folders be added to the processing - by default this is off
|
|
if scan folder overrides are set, on if not
|
|
:param add_config_scan_folders: Should config scan folders be added to the processing - by default this is off
|
|
if scan folder overrides are set, on if not
|
|
:param decode: decode byte strings from captured output to utf-8
|
|
:return: Pair: Success, output: True if all assets were processed successfully, False if the process times out
|
|
or returns errors. output is raw output from process
|
|
|
|
"""
|
|
logger.info(f"Starting {ap_path}")
|
|
command = [ap_path]
|
|
if fastscan:
|
|
command.append("--zeroAnalysisMode")
|
|
|
|
if self._project_path:
|
|
command.append(f'--regset="/Amazon/AzCore/Bootstrap/project_path={self._project_path}"')
|
|
|
|
# When using a scratch workspace we will set several options. The asset root controls where our
|
|
# cache lives.
|
|
# The logDir gives us a unique directory to place our logs in, and indicates we wish to randomize our
|
|
# listening port to avoid collisions. The port will be written to the logs which we'll retrieve it from
|
|
if self._temp_asset_root:
|
|
command.append("--assetroot")
|
|
command.append(f"{self._temp_asset_root}")
|
|
command.append(f'--regset="/Amazon/AzCore/Bootstrap/project_cache_path={self.temp_project_cache_path()}"')
|
|
command.append("--randomListeningPort")
|
|
if self._temp_log_root:
|
|
command.append("--logDir")
|
|
command.append(f"{self._temp_log_root}")
|
|
if self._override_scan_folders:
|
|
command.append("--scanfolders")
|
|
command.append(f"{','.join(self._override_scan_folders)}")
|
|
if add_gem_scan_folders is None:
|
|
add_gem_scan_folders = not self._override_scan_folders
|
|
if add_config_scan_folders is None:
|
|
add_config_scan_folders = not self._override_scan_folders
|
|
if not add_gem_scan_folders:
|
|
command.append("--noGemScanFolders")
|
|
if not add_config_scan_folders:
|
|
command.append("--noConfigScanFolders")
|
|
if scan_folder_pattern:
|
|
command.append("--scanfolderpattern")
|
|
if not isinstance(scan_folder_pattern, list):
|
|
scan_folder_pattern = [scan_folder_pattern]
|
|
command.append(f"{','.join(scan_folder_pattern)}")
|
|
|
|
if self._disable_all_platforms:
|
|
command.append(f'--regremove="f{ASSET_PROCESSOR_SETTINGS_ROOT_KEY}/Platforms"')
|
|
if platforms:
|
|
if isinstance(platforms, list):
|
|
platforms = ','.join(platforms)
|
|
command.append(f'--platforms={platforms}')
|
|
for key, value in self._enabled_platform_overrides:
|
|
command.append(f'--regset="f{ASSET_PROCESSOR_SETTINGS_ROOT_KEY}/Platforms/{key}={value}"')
|
|
|
|
if extra_params:
|
|
if isinstance(extra_params, list):
|
|
command.extend(extra_params)
|
|
else:
|
|
command.append(extra_params)
|
|
return command
|
|
|
|
def run_ap_process_command(self, command, timeout=DEFAULT_TIMEOUT_SECONDS, decode=True, capture_output=False,
|
|
expect_failure=False):
|
|
"""
|
|
In case of a timeout, the asset processor and associated processes are killed and the function returns False.
|
|
|
|
:param timeout: seconds to wait before aborting
|
|
:param capture_output = Capture output which will be returned in the second of the return pair
|
|
:param decode: decode byte strings from captured output to utf-8
|
|
:param expect_failure: asset processing is expected to fail, so don't error on a failure, and assert on no failure.
|
|
"""
|
|
logger.info(f"Launching AP with command: {command}")
|
|
start = datetime.datetime.now()
|
|
try:
|
|
duration = datetime.timedelta(seconds=timeout)
|
|
except TypeError:
|
|
logger.warning("Cannot set timeout value of '{}' seconds, defaulting to {} hours".format(
|
|
timeout, DEFAULT_TIMEOUT_HOURS))
|
|
duration = datetime.timedelta(hours=DEFAULT_TIMEOUT_HOURS)
|
|
timeout = duration.total_seconds()
|
|
|
|
run_result = subprocess.run(command, close_fds=True, timeout=timeout, capture_output=capture_output)
|
|
output_list = None
|
|
if capture_output:
|
|
output_list = run_result.stdout.split(b"\r\n")
|
|
if decode:
|
|
output_list = [line.decode('utf-8') for line in output_list]
|
|
|
|
if run_result.returncode != 0:
|
|
errorMessage = f"{command} returned error code: {run_result.returncode}"
|
|
if expect_failure:
|
|
logger.info(f"Expected error occurred. {errorMessage}")
|
|
else:
|
|
logger.error(errorMessage)
|
|
self.check_copy_logs()
|
|
return False, output_list
|
|
elif expect_failure:
|
|
logger.error(f"{command} was expected to fail, but instead ran without failure.")
|
|
return True, output_list
|
|
logger.info(
|
|
f"{command} completed successfully in {(datetime.datetime.now() - start).seconds} seconds")
|
|
return True, output_list
|
|
|
|
def set_failure_log_folder(self, log_root):
|
|
self._failed_log_root = log_root
|
|
|
|
def check_copy_logs(self):
|
|
if self._temp_asset_root and self._failed_log_root:
|
|
source_path = os.path.join(self._temp_asset_root, "logs")
|
|
dest_path = os.path.join(self._failed_log_root, f"{self._function_name + '.' if self._function_name else ''}{int(round(time.time() * 1000))}")
|
|
logger.info(f"Copying {source_path} to {dest_path}")
|
|
shutil.copytree(source_path, dest_path)
|
|
|
|
def disable_all_asset_processor_platforms(self):
|
|
"""
|
|
Disables all platforms via the SettingsRegistry --regremove option
|
|
|
|
:return: None
|
|
"""
|
|
self._disable_all_platforms = True
|
|
|
|
def enable_asset_processor_platform(self, platform):
|
|
# type: (str) -> None
|
|
"""
|
|
Add the platform to the dict of platforms to enable via the SettingsRegistry --regset option
|
|
|
|
:param platform: The platform to enable
|
|
:return: None
|
|
"""
|
|
self._enabled_platform_overrides[platform] = 'enabled'
|
|
|
|
def temp_asset_root(self):
|
|
"""
|
|
Returns the path to the "Temp asset root" which acts as a temporary folder approximating the engine's
|
|
/dev folder.
|
|
|
|
:return: Absolute path of current temp asset root
|
|
"""
|
|
return self._temp_asset_root
|
|
|
|
def _copy_asset_root_files(self):
|
|
"""
|
|
Copies the minimum required files for AP Batch to run from our current workspace dev environment to our
|
|
"temp asset root" workspace
|
|
|
|
:return: None
|
|
"""
|
|
for copy_dir in [self._workspace.project, 'Engine']:
|
|
make_dir = os.path.join(self._temp_asset_root, copy_dir)
|
|
if not os.path.isdir(make_dir):
|
|
os.makedirs(make_dir)
|
|
for copyfile_name in ['bootstrap.cfg',
|
|
'AssetProcessorPlatformConfig.setreg',
|
|
os.path.join(self._workspace.project, "project.json"),
|
|
os.path.join('Engine', 'exclude.filetag')]:
|
|
shutil.copyfile(os.path.join(self._workspace.paths.engine_root(), copyfile_name),
|
|
os.path.join(self._temp_asset_root, copyfile_name))
|
|
|
|
def delete_temp_asset_root(self):
|
|
"""
|
|
Cleanup our current temporary asset directory
|
|
|
|
:return: None
|
|
"""
|
|
if self._temp_asset_directory:
|
|
self._temp_asset_directory = None
|
|
|
|
def _del_readonly(self, action, name, exc):
|
|
os.chmod(name, stat.S_IWRITE)
|
|
if not os.access(name, os.W_OK):
|
|
os.chmod(name, stat.S_IWUSR)
|
|
try:
|
|
os.remove(name)
|
|
except OSError as e:
|
|
logger.error('Failed to clean up {} : {}'.format(name, e))
|
|
|
|
def delete_temp_asset_root_folder(self):
|
|
"""
|
|
Clean the contents of our temporary asset root folder
|
|
|
|
:return: None
|
|
"""
|
|
if self._temp_asset_root:
|
|
logger.info(f'Cleaning up old asset root at {self._temp_asset_root}')
|
|
shutil.rmtree(self._temp_asset_root, onerror=self._del_readonly)
|
|
|
|
def create_temp_asset_root(self, project_scan_folder=True):
|
|
"""
|
|
Create a new temporary asset root folder. Cleans up the previous one if set.
|
|
Copies the minimum necessary files to the workspace for APBatch to run
|
|
|
|
:param project_scan_folder: Set the current active project's folder as one of our scan folders
|
|
:return: None
|
|
"""
|
|
if self._temp_asset_root:
|
|
logger.info(f'Cleaning up old asset root at {self._temp_asset_root}')
|
|
shutil.rmtree(self._temp_asset_root, True)
|
|
self._temp_asset_directory = tempfile.TemporaryDirectory()
|
|
self._temp_asset_root = self._temp_asset_directory.name
|
|
self._copy_asset_root_files()
|
|
self._project_path = os.path.join(self._temp_asset_root, self._workspace.project)
|
|
if project_scan_folder:
|
|
self.add_scan_folder(self._project_path)
|
|
|
|
def log_root(self):
|
|
"""
|
|
Return the temp log root
|
|
"""
|
|
return self._temp_log_root
|
|
|
|
def create_temp_log_root(self):
|
|
"""
|
|
Create a temp folder for logs. We want a unique temp folder for logs for each test using the AssetProcessor
|
|
test class rather than using the default logs folder which could contain any old data or be used by other
|
|
runs of asset processor.
|
|
"""
|
|
if self._temp_log_root:
|
|
logger.info(f'Cleaning up old log root at {self._temp_log_root}')
|
|
shutil.rmtree(self._temp_log_root, True)
|
|
self._temp_log_directory = tempfile.TemporaryDirectory()
|
|
self._temp_log_root = self._temp_log_directory.name
|
|
self._workspace.paths.set_ap_log_root(self._temp_log_root)
|
|
|
|
def add_scan_folder(self, folder_name) -> str:
|
|
"""
|
|
Add a new folder as one of the default folders which AP Batch should scan on the next run
|
|
|
|
:param folder_name: Path to the folder to use. Absolute folders will be accepted as is, relative folders
|
|
assume relative from the current temp asset root if set or otherwise the path to the workspace's dev folder
|
|
:return: Absolute path of added scan folder
|
|
"""
|
|
if os.path.isabs(folder_name):
|
|
if not folder_name in self._override_scan_folders:
|
|
self._override_scan_folders.append(folder_name)
|
|
logger.info(f'Adding override scan folder {folder_name}')
|
|
return folder_name
|
|
else:
|
|
if not self._temp_asset_root:
|
|
logger.warning(f"Can't create scan folder, no temporary asset workspace has been created")
|
|
return
|
|
scan_folder = os.path.join(self._temp_asset_root if self._temp_asset_root else self._workspace.paths.engine_root(),
|
|
folder_name)
|
|
if not os.path.isdir(scan_folder):
|
|
os.makedirs(scan_folder)
|
|
if folder_name not in self._override_scan_folders:
|
|
self._override_scan_folders.append(scan_folder)
|
|
logger.info(f"Adding scan folder {scan_folder}")
|
|
return scan_folder
|
|
|
|
def clear_scan_folders(self) -> None:
|
|
"""
|
|
Remove all override scan folders
|
|
|
|
:return: None
|
|
"""
|
|
self._override_scan_folders = None
|
|
|
|
def create_temp_asset(self, file_path, content):
|
|
"""
|
|
Helper to create a simple test asset under the asset root.
|
|
|
|
:param file_path: Relative path under the temp asset root to the file to create
|
|
:param content: Content to write into the file
|
|
:return: None
|
|
"""
|
|
write_path = os.path.join(self._temp_asset_root, file_path)
|
|
f = open(write_path, 'w')
|
|
f.write(content)
|
|
f.close()
|
|
logger.info('Wrote to asset ')
|
|
|
|
def prepare_test_environment(self, assets_path: str, function_name: str, use_current_root=False,
|
|
relative_asset_root=None, add_scan_folder=True, cache_platform=None,
|
|
existing_function_name=None) -> str:
|
|
"""
|
|
Creates a temporary test workspace, copies the specified test assets and sets the folder as a scan
|
|
folder for processing.
|
|
|
|
:param assets_path: Path to tests assets folder
|
|
:param function_name: Name of a function that corresponds to folder with assets
|
|
:param use_current_root: Expect an already created asset root we wish to re-use
|
|
:param relative_asset_root: If the function folder should live somewhere else relative to the temp_asset_root
|
|
than the Project folder. Use '' for temp_asset_root
|
|
:param add_scan_folder: Should the new assets be automatically added as a scan folder
|
|
:param cache_platform: Cache platform to use in returned cache path, or assumes workspace asset processor
|
|
platform
|
|
:return: Returning path to copied assets folder
|
|
"""
|
|
if not use_current_root:
|
|
self.create_temp_asset_root()
|
|
test_asset_root = os.path.join(self._temp_asset_root, self._workspace.project if relative_asset_root is None
|
|
else relative_asset_root)
|
|
test_folder = os.path.join(test_asset_root, function_name if existing_function_name is None
|
|
else existing_function_name)
|
|
if not os.path.isdir(test_folder):
|
|
os.makedirs(test_folder)
|
|
if add_scan_folder:
|
|
self.add_scan_folder(test_asset_root)
|
|
self._function_name = function_name
|
|
self._assets_source_folder = utils.prepare_test_assets(assets_path, function_name, test_folder)
|
|
self._test_assets_source_folder = test_folder
|
|
self._cache_folder = os.path.join(self._temp_asset_root, 'Cache', cache_platform or
|
|
ASSET_PROCESSOR_PLATFORM_MAP[self._workspace.asset_processor_platform],
|
|
function_name.lower() if existing_function_name is None
|
|
else existing_function_name)
|
|
|
|
return self._test_assets_source_folder, self._cache_folder
|
|
|
|
def project_test_cache_folder(self):
|
|
"""
|
|
Get the project cache folder from a test run using prepare_test_environment
|
|
|
|
:return: path to folder
|
|
"""
|
|
return self._cache_folder
|
|
|
|
def assets_source_folder(self):
|
|
"""
|
|
Get the original project source folder from a test run using prepare_test_environment
|
|
which the assets under test were copied FROM into the test environment
|
|
|
|
:return: path to folder
|
|
"""
|
|
return self._assets_source_folder
|
|
|
|
def project_test_source_folder(self):
|
|
"""
|
|
Get the project source folder from a test run using prepare_test_environment
|
|
|
|
:return: path to folder
|
|
"""
|
|
return self._test_assets_source_folder
|
|
|
|
def compare_assets_with_cache(self) -> str:
|
|
"""
|
|
Helper to compare output assets from a test run using prepare_test_environment
|
|
|
|
:return: Results of the comparison as a pair (Missing Assets, Found matching assets) - see
|
|
pipeline_utils.compare_assets_with_cache for more info
|
|
"""
|
|
return utils.compare_assets_with_cache(os.listdir(self._test_assets_source_folder), self._cache_folder)
|
|
|
|
def clear_project_test_assets_dir(self):
|
|
"""
|
|
Helper to cleanup the test_assets_source_folder created for a test using prepare_test_environment
|
|
|
|
:return: None
|
|
"""
|
|
utils.clear_project_test_assets_dir(self._test_assets_source_folder)
|
|
|
|
def delete_temp_cache(self):
|
|
"""
|
|
Helper to clean up the current cache folder from a test using prepare_test_environment
|
|
|
|
:return: None
|
|
"""
|
|
shutil.rmtree(self._cache_folder, onerror=self._del_readonly)
|
|
|
|
def run_and_check_output(self, error_expected, error_search_terms, platforms=None, decode=True):
|
|
"""
|
|
Helper to call batch_process which respects temporary workspaces.
|
|
|
|
:param error_expected: Is the error_search terms list a list of errors which should or should not be found
|
|
:param error_search_terms: Errors to be found if error_expected is true, errors not to be found if false
|
|
:return: Output list
|
|
"""
|
|
_, output = self.batch_process(capture_output=True, platforms=platforms, decode=decode,
|
|
expect_failure=error_expected)
|
|
# Check for error in output
|
|
if error_expected:
|
|
utils.validate_log_output(output, error_search_terms, [])
|
|
else:
|
|
utils.validate_log_output(output, [], error_search_terms)
|
|
|
|
def add_relative_source_asset(self, relative_asset, source_root=None, dest_root=None):
|
|
"""
|
|
Helper to add a source asset from our workspace to our temporary asset workspace
|
|
|
|
:param relative_asset: Relative path to the asset under dev which should be added
|
|
:param source_root: Path to the folder which relative_asset is relative to. workspace's dev folder if not
|
|
supplied
|
|
:param dest_root: Path to the folder which relative asset should be output relative to. Current temp
|
|
asset root if not supplied
|
|
:return: None
|
|
"""
|
|
source_root = source_root or self._workspace.paths.engine_root()
|
|
if not self._temp_asset_root:
|
|
logger.warning(f"Can't add relative source asset, no temporary asset root created")
|
|
return
|
|
dest_root = dest_root or self._temp_asset_root
|
|
make_dir = os.path.dirname(os.path.join(dest_root, relative_asset))
|
|
if not os.path.isdir(make_dir):
|
|
os.makedirs(make_dir)
|
|
shutil.copyfile(os.path.join(source_root, relative_asset),
|
|
os.path.join(dest_root, relative_asset))
|
|
|
|
def temp_project_cache(self, project_name=None, asset_platform=None):
|
|
"""
|
|
Getter to return the absolute path to the temp workspace's project cache
|
|
|
|
:param project_name: Project name to use, defaults to workspace's project name
|
|
:param asset_platform: Asset platform to use. Workspace's asset platform if not supplied
|
|
:return: path to project cache
|
|
"""
|
|
asset_platform = asset_platform or ASSET_PROCESSOR_PLATFORM_MAP[self._workspace.asset_processor_platform]
|
|
return os.path.join(self._temp_asset_root, 'Cache',
|
|
asset_platform.lower())
|
|
|
|
def temp_project_cache_path(self, project_name=None):
|
|
"""
|
|
Get the project cache root folder from a test run using prepare_test_environment
|
|
The project cache root folder does not include the asset platform
|
|
|
|
:param project_name: Project name to use, defaults to workspace's project name
|
|
|
|
:return: path to project cache root folder
|
|
"""
|
|
return os.path.join(self._temp_asset_root, 'Cache')
|
|
|
|
def clear_readonly(self, relative_dest):
|
|
"""
|
|
Clear the readonly flag on a relative file or all files within a directory underneath the temporary
|
|
asset root (Only works within temporary directory)
|
|
|
|
:param relative_dest: Relative path to file or folder within the temp workspace which should be cleared
|
|
of readonly flag
|
|
:return: None
|
|
"""
|
|
if not self._temp_asset_root:
|
|
logger.warning("clear_readonly called without temp_asset_root set")
|
|
return
|
|
clear_path = os.path.join(self._temp_asset_root, relative_dest)
|
|
if os.path.isfile(clear_path):
|
|
logger.info(f"Clearing readonly flag for {clear_path}")
|
|
os.chmod(clear_path, stat.S_IWRITE)
|
|
elif os.path.isdir(clear_path):
|
|
logger.info(f"Clearing readonly flag for files in {clear_path}")
|
|
file_system.change_permissions(clear_path, stat.S_IWRITE)
|
|
else:
|
|
logger.warning(f"clear_readonly called with invalid path {clear_path}")
|
|
|
|
def add_source_folder_assets(self, relative_source, relative_dest=None):
|
|
"""
|
|
Add an entire tree of source assets to our temporary workspace
|
|
|
|
:param relative_source: path to relative folder under workspace's dev folder to start at
|
|
:param relative_dest: Destination within the temporary workspace to copy to. relative_source if not
|
|
supplied
|
|
:return: path to project cache
|
|
"""
|
|
source_folder = os.path.join(self._workspace.paths.engine_root(), relative_source)
|
|
dest_relative = relative_dest or relative_source
|
|
dest_folder = os.path.join(self._temp_asset_root, dest_relative)
|
|
shutil.copytree(source_folder, dest_folder)
|
|
self.clear_readonly(dest_relative)
|
|
|
|
@staticmethod
|
|
def copy_assets_to_project(assets: List[str], source_directory: str, target_asset_dir: str) -> None:
|
|
"""
|
|
Simple wrapper for pipeline_utils' copy_assets_to_project to remove the need to import pipeline_utils
|
|
|
|
:return: None
|
|
"""
|
|
utils.copy_assets_to_project(assets, source_directory, target_asset_dir)
|
|
|
|
|
|
def _build_ap_batch_call_params(ap_path, project, platforms, extra_params=None):
|
|
project_str = f"--regset=/Amazon/AzCore/Bootstrap/project_path={project}"
|
|
project_str.rstrip(' ')
|
|
param_list = [ap_path, project_str]
|
|
|
|
if self._disable_all_platforms:
|
|
command.append(f'--regremove="f{ASSET_PROCESSOR_SETTINGS_ROOT_KEY}/Platforms"')
|
|
if platforms:
|
|
if isinstance(platforms, list):
|
|
platforms = ','.join(platforms)
|
|
param_list.append(f'--platforms={platforms}')
|
|
for key, value in self._enabled_platform_overrides:
|
|
param_list.append(f'--regset="f{ASSET_PROCESSOR_SETTINGS_ROOT_KEY}/Platforms/{key}={value}"')
|
|
|
|
if extra_params:
|
|
if isinstance(extra_params, list):
|
|
param_list.extend(extra_params)
|
|
else:
|
|
param_list.append(extra_params)
|
|
return param_list
|
|
|
|
|
|
def assetprocessorbatch_check_output(workspace, project=None, platforms=None, extra_params=None, log_info=False,
|
|
ap_override_path=None, no_split=False, expect_failure=False):
|
|
ap_path = ap_override_path or workspace.paths.asset_processor_batch()
|
|
project = project or workspace.project
|
|
platforms = platforms or ASSET_PROCESSOR_PLATFORM_MAP[workspace.asset_processor_platform]
|
|
param_list = _build_ap_batch_call_params(ap_path, project, platforms, extra_params)
|
|
try:
|
|
output_list = subprocess.check_output(param_list).decode('utf-8')
|
|
if not no_split:
|
|
output_list = output_list.split("\r\n")
|
|
if log_info:
|
|
logger.info('AssetProcessorBatch output:\n{}'.format(output_list))
|
|
return output_list
|
|
except subprocess.CalledProcessError as e:
|
|
if not expect_failure:
|
|
logger.error(f"AssetProcessorBatch returned error {ap_path} with error {e}")
|
|
# This will sometimes be due to expected asset processing errors - we'll return the output and let the tests
|
|
# decide what to do
|
|
if not no_split:
|
|
return e.output.decode('utf-8').split("\r\n")
|
|
return e.output.decode('utf-8')
|
|
except FileNotFoundError as e:
|
|
logger.error(f"File Not Found - Failed to call {ap_path} with error {e}")
|
|
assert not expect_failure, "AP failure didn't occur as expected"
|
|
|
|
|
|
def parse_output_value(output_list, start_value, end_value=None):
|
|
for output in output_list:
|
|
found_pos = output.find(start_value)
|
|
if found_pos >= 0:
|
|
start_pos = found_pos + len(start_value)
|
|
if not end_value:
|
|
return output[start_pos:]
|
|
end_pos = output.find(end_value, start_pos)
|
|
if end_pos == -1:
|
|
continue
|
|
return output[start_pos:end_pos]
|
|
return None
|
|
|
|
|
|
def get_num_processed_assets(output):
|
|
result = parse_output_value(output, 'Number of Assets Successfully Processed:', '.')
|
|
logger.info(f'Assets Successfully Processed returned {result}')
|
|
return -1 if not result else int(result)
|
|
|
|
|
|
def get_num_failed_processed_assets(output):
|
|
result = parse_output_value(output, 'Number of Assets Failed to Process:', '.')
|
|
logger.info(f'Failed assets returned {result}')
|
|
return -1 if not result else int(result)
|
|
|
|
|
|
def has_invalid_server_address(output):
|
|
return parse_output_value(output, 'Invalid server address') is not None
|
|
|