common.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  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. #
  5. # SPDX-License-Identifier: Apache-2.0 OR MIT
  6. #
  7. #
  8. import configparser
  9. import hashlib
  10. import logging
  11. import json
  12. import os
  13. import re
  14. import shutil
  15. import stat
  16. import string
  17. import subprocess
  18. import pathlib
  19. import platform
  20. from subprocess import CalledProcessError
  21. from packaging.version import Version
  22. from cmake.Tools import layout_tool
  23. # Text encoding Constants for reading/writing to files.
  24. DEFAULT_TEXT_READ_ENCODING = 'UTF-8' # The default encoding to use when reading from a text file
  25. DEFAULT_TEXT_WRITE_ENCODING = 'ascii' # The encoding to use when writing to a text file
  26. ENCODING_ERROR_HANDLINGS = 'ignore' # What to do if we encounter any encoding errors
  27. DEFAULT_PAK_ROOT = 'Pak' # The default Pak root folder under engine root where the game paks are built
  28. if platform.system() == 'Windows':
  29. class PlatformError(WindowsError):
  30. pass
  31. # Re-use microsoft error codes since this script is meant to only run on windows host platforms
  32. ERROR_CODE_FILE_NOT_FOUND = 2
  33. ERROR_CODE_ERROR_NOT_SUPPORTED = 50
  34. ERROR_CODE_INVALID_PARAMETER = 87
  35. ERROR_CODE_CANNOT_COPY = 266
  36. ERROR_CODE_ERROR_DIRECTORY = 267
  37. else:
  38. class PlatformError(Exception):
  39. pass
  40. # Posix does not match any of the following errors to specific codes, so just the standard '1'
  41. ERROR_CODE_FILE_NOT_FOUND = 1
  42. ERROR_CODE_ERROR_NOT_SUPPORTED = 1
  43. ERROR_CODE_INVALID_PARAMETER = 1
  44. ERROR_CODE_CANNOT_COPY = 1
  45. ERROR_CODE_ERROR_DIRECTORY = 1
  46. # Specific error codes that we cant match to any platform code, so just set to the standard '1'
  47. ERROR_CODE_ENVIRONMENT_ERROR = 1
  48. ERROR_CODE_GENERAL_ERROR = 1
  49. ENGINE_ROOT_CHECK_FILE = 'engine.json'
  50. HASH_CHUNK_SIZE = 200000
  51. class LmbrCmdError(Exception):
  52. """
  53. Wrapper class to the general exception class where will absorb and prevent the printing of stack.
  54. We will rely on specific error conditions instead.
  55. """
  56. def __init__(self, msg, code=ERROR_CODE_GENERAL_ERROR):
  57. """
  58. Init the class
  59. :param msg: The detailed error message to print out
  60. :param code: The return code to return from the command line execution
  61. """
  62. self.msg = msg
  63. self.code = code
  64. def __str__(self):
  65. return str(self.msg)
  66. def read_project_name_from_project_json(project_path):
  67. project_name = None
  68. try:
  69. with (pathlib.Path(project_path) / 'project.json').open('r') as project_file:
  70. project_json = json.load(project_file)
  71. project_name = project_json['project_name']
  72. except OSError as os_error:
  73. logging.warning(f'Unable to open "project.json" file: {os_error}')
  74. except json.JSONDecodeError as json_error:
  75. logging.warning(f'Unable to decode json in {project_file}: {json_error}')
  76. except KeyError as key_error:
  77. logging.warning(f'{project_file} is missing project_name key: {key_error}')
  78. return project_name
  79. def determine_engine_root(starting_path=None):
  80. """
  81. Determine the engine root of the engine. By default, the engine root is the engine path, which is determined by walking
  82. up the current working directory until we find the engine.json marker
  83. :param starting_path: Optional starting path to look for the engine.json marker file, otherwise use the current working path
  84. :return: The root path that is validated to contain engine.json if found, None if not found
  85. """
  86. current_path = os.path.normpath(starting_path or os.getcwd())
  87. check_file = os.path.join(current_path, ENGINE_ROOT_CHECK_FILE)
  88. while not os.path.isfile(check_file):
  89. next_path = os.path.dirname(current_path)
  90. if next_path == current_path:
  91. # If going up one level results in the same path, we've hit the root
  92. break
  93. check_file = os.path.join(next_path, ENGINE_ROOT_CHECK_FILE)
  94. current_path = next_path
  95. if not os.path.isfile(check_file):
  96. return None
  97. return current_path
  98. def get_config_file_values(config_file_path, keys_to_extract):
  99. """
  100. Read an o3de config file and extract specific keys if they are set
  101. @param config_file_path: The o3de config file to parse
  102. @param keys_to_extract: The specific keys to lookup the values if set
  103. @return: Dictionary of keys and its values (for matched keys)
  104. """
  105. result_map = {}
  106. with open(config_file_path, 'r') as config_file:
  107. bootstrap_contents = config_file.read()
  108. for search_key in keys_to_extract:
  109. search_result = re.search(r'^\s*{}\s*=\s*([\w\.]+)'.format(search_key), bootstrap_contents, re.MULTILINE)
  110. if search_result:
  111. result_value = search_result.group(1)
  112. result_map[search_key] = result_value
  113. return result_map
  114. def get_bootstrap_values(bootstrap_dir, keys_to_extract):
  115. """
  116. Extract requested values from the bootstrap.setreg file in the Registry folder
  117. :param bootstrap_dir: The parent directory of the bootstrap.setreg file
  118. :param keys_to_extract: The keys to extract into a dictionary
  119. :return: Dictionary of keys and its values (for matched keys)
  120. """
  121. bootstrap_file = os.path.join(bootstrap_dir, 'bootstrap.setreg')
  122. if not os.path.isfile(bootstrap_file):
  123. raise logging.error(f'Bootstrap.setreg file {bootstrap_file} does not exist.')
  124. result_map = {}
  125. with open(bootstrap_file, 'r') as f:
  126. try:
  127. json_data = json.load(f)
  128. except Exception as e:
  129. logging.error(f'Bootstrap.setreg failed to load: {str(e)}')
  130. else:
  131. for search_key in keys_to_extract:
  132. try:
  133. search_result = json_data["Amazon"]["AzCore"]["Bootstrap"][search_key]
  134. except KeyError as e:
  135. logging.warning(f'Bootstrap.setreg cannot find /Amazon/AzCore/Bootstrap/{search_key}: {str(e)}')
  136. else:
  137. result_map[search_key] = search_result
  138. return result_map
  139. def validate_ap_config_asset_type_enabled(engine_root, bootstrap_asset_type):
  140. """
  141. Validate that the requested bootstrap asset type was enabled in the asset processor configuration file
  142. :param engine_root: The engine root to lookup the AP config file
  143. :param bootstrap_asset_type: The asset type to validate
  144. :return: True if the asset type was enabled, false if not
  145. """
  146. ap_config_file = os.path.join(engine_root, 'Registry', 'AssetProcessorPlatformConfig.setreg')
  147. if not os.path.isfile(ap_config_file):
  148. raise LmbrCmdError("Missing required asset processor configuration file at '{}'".format(engine_root),
  149. ERROR_CODE_FILE_NOT_FOUND)
  150. parser = configparser.ConfigParser()
  151. parser.read([ap_config_file])
  152. if parser.has_option('Platforms', bootstrap_asset_type):
  153. enabled_value = parser.get('Platforms', bootstrap_asset_type)
  154. else:
  155. # If 'pc' is not set, then default it to 'disable'. For other platforms, their default is 'disabled'
  156. enabled_value = 'disabled' if bootstrap_asset_type != 'pc' else 'enabled'
  157. return enabled_value == 'enabled'
  158. def file_fingerprint(path, deep_check=False):
  159. """
  160. Calculate a file hash for for a file from either its metadata or its content (deep_check=True)
  161. :param path: The absolute path to the file to check its fingerprint. (Does not work on directories)
  162. :param deep_check: Flag to use a deep check (hash of the entire file content) or just its metadata (timestamp+size)
  163. :return: The hash
  164. """
  165. if os.path.isdir(path):
  166. raise LmbrCmdError("Cannot fingerprint '{}' because its a directory".format(path),
  167. ERROR_CODE_ERROR_DIRECTORY)
  168. # Use MD5 hash
  169. hasher = hashlib.md5()
  170. # Always start with a shallow check: Start the hash by hashing the mod-time and file size
  171. path_file_stat = os.stat(path)
  172. hasher.update(str(path_file_stat.st_mtime).encode('UTF-8'))
  173. hasher.update(str(path_file_stat.st_size).encode('UTF-8'))
  174. # If doing a deep check, also include the contents
  175. if deep_check:
  176. with open(path, 'rb') as file_to_hash:
  177. while True:
  178. content = file_to_hash.read(HASH_CHUNK_SIZE)
  179. hasher.update(content)
  180. if not content:
  181. break
  182. return hasher.hexdigest()
  183. def load_template_file(template_file_path, template_env):
  184. """
  185. Helper method to load in a template file and return the processed template based on the input template environment
  186. This will also handle '###' tokens to strip out of the final output completely to support things like adding
  187. copyrights to the template that is not intended for the output text
  188. :param template_file_path: The path to the template file to load
  189. :param template_env: The template environment dictionary for the template file to process
  190. :return: The processed content from the template file
  191. :raises: FileNotFoundError: If the template file path cannot be found
  192. """
  193. try:
  194. template_file_content = template_file_path.resolve(strict=True).read_text(encoding=DEFAULT_TEXT_READ_ENCODING,
  195. errors=ENCODING_ERROR_HANDLINGS)
  196. # Filter out all lines that start with '###' before replacement
  197. filtered_template_file_content = (str(re.sub('###.*', '', template_file_content)).strip())
  198. return string.Template(filtered_template_file_content).substitute(template_env)
  199. except FileNotFoundError:
  200. raise FileNotFoundError(f"Invalid file path. Cannot find template file located at {str(template_file_path)}")
  201. # Determine the possible file extensions for executable files based on the host platform
  202. PLATFORM_EXECUTABLE_EXTENSIONS = [''] # Files without extensions are always considered
  203. if platform.system() == 'Windows':
  204. # Windows manages its executable extensions through the %PATHEXT% environment variable
  205. path_extensions_str = os.environ.get('PATHEXT', default='.EXE;.COM;.BAT;.CMD')
  206. PLATFORM_EXECUTABLE_EXTENSIONS.extend([pathext.lower() for pathext in path_extensions_str.split(';')])
  207. elif platform.system() == 'Linux':
  208. PLATFORM_EXECUTABLE_EXTENSIONS = ['', '.out']
  209. else:
  210. PLATFORM_EXECUTABLE_EXTENSIONS = ['']
  211. def verify_tool(override_tool_path, tool_name, tool_filename, argument_name, tool_version_argument, tool_version_regex, min_version, max_version):
  212. """
  213. Support method to validate a required system tool needed for the build either through an installed tool in the
  214. environment path, or an override path provided
  215. :param override_tool_path: The override path to use to locate the tool's binary. If not provided, the rely on the fact that the tool is in the PATH enviroment
  216. :param tool_name: The name of the tool to present to the output
  217. :param tool_filename: The filename to use to search for when the override path is provided
  218. :param argument_name: The name of the command line argument to display to the user if needed
  219. :param tool_version_argument: The argument that the tool expects when querying for the version
  220. :param tool_version_regex: The regex used to parse the version number from the 'version' argument
  221. :param min_version: Optional min_version to validate against. (None to skip min version validation)
  222. :param max_version: Optional max_version to validate against. (None to skip max version validation)
  223. :return: Tuple of the resolved tool version and the resolved override tool path if provided
  224. """
  225. tool_source = tool_name
  226. try:
  227. # Use either the provided gradle override or the gradle in the path environment
  228. if override_tool_path:
  229. # The path can be either to an install base folder or its actual 'bin' folder
  230. if isinstance(override_tool_path, str):
  231. override_tool_path = pathlib.Path(override_tool_path)
  232. elif not isinstance(override_tool_path, pathlib.Path):
  233. raise LmbrCmdError(f"Invalid {tool_name} path argument. '{override_tool_path}' must be a string or Path",
  234. ERROR_CODE_INVALID_PARAMETER)
  235. file_found = False
  236. for executable_path_ext in PLATFORM_EXECUTABLE_EXTENSIONS:
  237. check_tool_filename = f'{tool_filename}{executable_path_ext}'
  238. check_tool_path = override_tool_path / check_tool_filename
  239. if check_tool_path.is_file():
  240. file_found = True
  241. break
  242. check_tool_path = override_tool_path / 'bin' / check_tool_filename
  243. if check_tool_path.is_file():
  244. file_found = True
  245. break
  246. if not file_found:
  247. raise LmbrCmdError(f"Invalid {tool_name} path argument. '{override_tool_path}' is not a valid {tool_name} path",
  248. ERROR_CODE_INVALID_PARAMETER)
  249. resolved_override_tool_path = str(check_tool_path.resolve())
  250. tool_source = str(check_tool_path.resolve())
  251. tool_desc = f"{tool_name} path provided in the command line argument '{argument_name}={override_tool_path}' "
  252. else:
  253. resolved_override_tool_path = None
  254. tool_desc = f"installed {tool_name} in the system path"
  255. # Extract the version and verify
  256. version_output = subprocess.check_output([tool_source, tool_version_argument],
  257. shell=(platform.system() == 'Windows'),
  258. stderr=subprocess.PIPE).decode(DEFAULT_TEXT_READ_ENCODING,
  259. ENCODING_ERROR_HANDLINGS)
  260. version_match = tool_version_regex.search(version_output)
  261. if not version_match:
  262. raise RuntimeError()
  263. # Since we are doing a compare, strip out any non-numeric and non . character from the version otherwise we will get a TypeError on the Version comparison
  264. result_version_str = re.sub(r"[^\.0-9]", "", str(version_match.group(1)).strip())
  265. result_version = Version(result_version_str)
  266. if min_version and result_version < min_version:
  267. raise LmbrCmdError(f"The {tool_desc} does not meet the minimum version of {tool_name} required ({str(min_version)}).",
  268. ERROR_CODE_ENVIRONMENT_ERROR)
  269. elif max_version and result_version > max_version:
  270. raise LmbrCmdError(f"The {tool_desc} exceeds maximum version of {tool_name} supported ({str(max_version)}).",
  271. ERROR_CODE_ENVIRONMENT_ERROR)
  272. return result_version, resolved_override_tool_path
  273. except CalledProcessError as e:
  274. error_msg = e.output.decode(DEFAULT_TEXT_READ_ENCODING,
  275. ENCODING_ERROR_HANDLINGS)
  276. raise LmbrCmdError(f"{tool_name} cannot be resolved or there was a problem determining its version number. "
  277. f"Either make sure its in the system path environment or a valid path is passed in "
  278. f"through the {argument_name} argument.\n{error_msg}",
  279. ERROR_CODE_ERROR_NOT_SUPPORTED)
  280. except (PlatformError, RuntimeError) as e:
  281. logging.error(f"Call to '{tool_source}' resulted in error: {e}")
  282. raise LmbrCmdError(f"{tool_name} cannot be resolved or there was a problem determining its version number. "
  283. f"Either make sure its in the system path environment or a valid path is passed in "
  284. f"through the {argument_name} argument.",
  285. ERROR_CODE_ERROR_NOT_SUPPORTED)
  286. def verify_project_and_engine_root(project_root, engine_root):
  287. """
  288. Verify the engine root folder and the project root folder. This will perform basic minimal checks
  289. for validation:
  290. 1. Make sure ${engine_root}/engine.json
  291. 2. Make sure ${project_root}/project.json exists
  292. 3. Make sure that the project.json minimally has a json structure with a 'project_name' attribute
  293. The project name will be verified by returning the value of 'project_name' from the json file to minimize issues
  294. on case-insensitive file systems because we rely on the fact that the project name matches the folder in which it resides
  295. :param project_root: The project root to verify. If None, skip the project name verification
  296. :param engine_root: The engine root directory to verify
  297. :return: A tuple of the actual 'project_name' from the game's project.json and the pathlib.Path of the engine root if verified
  298. """
  299. engine_root_path = pathlib.Path(engine_root)
  300. if not engine_root_path.exists():
  301. raise LmbrCmdError(f"Invalid engine root path ({engine_root})",
  302. ERROR_CODE_INVALID_PARAMETER)
  303. # Sanity check: engine.json
  304. engine_json_path = engine_root_path / ENGINE_ROOT_CHECK_FILE
  305. if not engine_json_path.exists():
  306. raise LmbrCmdError(f"Invalid engine root path ({engine_root}). Missing {ENGINE_ROOT_CHECK_FILE}",
  307. ERROR_CODE_INVALID_PARAMETER)
  308. if project_root is None:
  309. return None, engine_root_path
  310. else:
  311. project_path = engine_root_path / project_root
  312. project_path_project_properties = project_path / 'project.json'
  313. if not project_path_project_properties.is_file():
  314. raise LmbrCmdError(f'Invalid project at path "{project_path}". It is missing the project.json file',
  315. ERROR_CODE_INVALID_PARAMETER)
  316. project_name = read_project_name_from_project_json(project_path)
  317. if not project_name:
  318. raise LmbrCmdError(f'Invalid project at path "{project_path}". Its project.json does not contains a "project_name" key',
  319. ERROR_CODE_INVALID_PARAMETER)
  320. return project_path, engine_root_path
  321. def remove_dir_path(path):
  322. """
  323. Helper function to delete a folder, ignoring all errors if possible
  324. :param path: The Path to the folder to delete
  325. """
  326. if not path.is_dir() and path.is_file():
  327. # Avoid accidentally deleting a file
  328. raise RuntimeError("Cannot perform 'remove_dir_path' on file {path}. It must be a directory.")
  329. if path.exists():
  330. files_to_delete = []
  331. for root, dirs, files in os.walk(path):
  332. for file in files:
  333. files_to_delete.append(os.path.join(root, file))
  334. for file in files_to_delete:
  335. os.chmod(file, stat.S_IWRITE)
  336. os.remove(file)
  337. shutil.rmtree(path.resolve(), ignore_errors=True)
  338. def normalize_path_for_settings(path, escape_drive_sep=False):
  339. """
  340. Normalize a path for a settings file in case backslashes are treated as escape characters
  341. :param path: The path to process (string or pathlib.Path)
  342. :param escape_drive_sep: Option to escape any ':' driver separator (windows)
  343. :return: The normalized path
  344. """
  345. if isinstance(path, str):
  346. processed = path
  347. else:
  348. processed = str(path.resolve())
  349. processed = processed.replace('\\', '/')
  350. if escape_drive_sep:
  351. processed = processed.replace(':', '\\:')
  352. return processed
  353. def wrap_parsed_args(parsed_args):
  354. """
  355. Function to add a method to the parsed argument object to transform a long-form argument name to and get the
  356. parsed values based on the input long form.
  357. This will allow us to read an argument like '--foo-bar=Orange' by using the built in method rather than looking for
  358. the argparsed transformed attrobite 'foo_bar'
  359. :param parsed_args: The parsed args object to wrap
  360. """
  361. def parse_argument_attr(argument):
  362. argument_attr = argument[2:].replace('-', '_')
  363. return getattr(parsed_args, argument_attr)
  364. parsed_args.get_argument = parse_argument_attr
  365. class PlatformSettings(object):
  366. """
  367. Platform settings reader
  368. This will generate a simple settings object based on the cmake generated 'platform.settings' file
  369. generated by cmake/FileUtil.cmake
  370. """
  371. def __init__(self, build_dir):
  372. platform_last_file = build_dir / 'platform.settings'
  373. if not platform_last_file.exists():
  374. raise LmbrCmdError(f"Invalid build directory {build_dir}. Missing 'platform.settings'.")
  375. config = configparser.ConfigParser()
  376. config.read(platform_last_file)
  377. # Look up the general common settings across all platforms
  378. projects_str = config['settings']['game_projects']
  379. if projects_str:
  380. self.projects = projects_str.split(';')
  381. asset_deploy_mode = config['settings'].get('asset_deploy_mode')
  382. self.asset_deploy_mode = asset_deploy_mode if asset_deploy_mode else None
  383. asset_deploy_type = config['settings'].get('asset_deploy_type')
  384. self.asset_deploy_type = asset_deploy_type if asset_deploy_type else None
  385. self.override_pak_root = config['settings'].get('override_pak_root', '')
  386. # Apply all platform-specific settings under the '[<platform_name>]' section in the config file
  387. platform_name = config['settings']['platform']
  388. if platform_name in config.sections():
  389. platform_items = config.items(platform_name)
  390. for platform_item_key, platform_item_value in platform_items:
  391. # Prevent any custom platform setting to overwrite a common one
  392. if platform_item_key in ('asset_deploy_mode', 'asset_deploy_type', 'projects'):
  393. logging.warning(f"Reserved key '{platform_item_key}' found in platform section of {str(platform_last_file)}. Ignoring")
  394. continue
  395. setattr(self, platform_item_key, platform_item_value)
  396. def validate_build_dir_and_config(build_dir_name, configuration):
  397. """
  398. Validate the build directory and configuration. The current working directory must be the engine root
  399. :param build_dir_name: The name of the build directory
  400. :param configuration: The configuration name (debug, profile, or release)
  401. :return: tuple of pathlibs for the build directory, and the configuration directory
  402. """
  403. build_dir = pathlib.Path(os.getcwd()) / build_dir_name
  404. if not build_dir.is_dir():
  405. raise LmbrCmdError(f"Invalid build directory {build_dir_name}")
  406. build_config_dir = build_dir / 'bin' / configuration
  407. if not build_config_dir.is_dir():
  408. raise LmbrCmdError(f"Output path for build configuration {configuration} not found. Make sure that it was built.")
  409. return build_dir, build_config_dir
  410. def validate_deployment_arguments(build_dir_name, configuration, project_path):
  411. """
  412. Validate the minimal platform deployment arguments
  413. @param build_dir_name: The name of the build directory relative to the current working directory
  414. @param configuration: The configuration the deployment is based on
  415. @param project_path: The path the project to deploy
  416. @return: Tuple of (resolved build_dir, game name, asset mode, asset_type, and Pak root folder)
  417. """
  418. build_dir, build_config_dir = validate_build_dir_and_config(build_dir_name, configuration)
  419. platform_settings = PlatformSettings(build_dir)
  420. if not project_path:
  421. if not platform_settings.projects:
  422. raise LmbrCmdError("Missing required game project argument. Unable to determine a default one.")
  423. internal_project_path = pathlib.PurePath(platform_settings.projects[0]).resolve()
  424. logging.info(f"Using project_path '{internal_project_path}' as the game project")
  425. else:
  426. if project_path not in platform_settings.projects:
  427. raise LmbrCmdError(f"Game project {project_path} not valid. Was not configured for build directory {build_dir_name}.")
  428. return build_dir, project_path, platform_settings.asset_deploy_mode, platform_settings.asset_deploy_type, platform_settings.override_pak_root or DEFAULT_PAK_ROOT
  429. class CommandLineExec(object):
  430. def __init__(self, executable_path):
  431. if not os.path.isfile(executable_path):
  432. raise LmbrCmdError(f"Invalid command-line executable '{executable_path}'")
  433. self.executable_path = executable_path
  434. def exec(self, arguments, capture_stdout=False, suppress_stderr=False, cwd=None):
  435. """
  436. Wrapper to executing calls
  437. @param arguments: Arguments to pass to ''
  438. @param capture_stdout: If true, capture the stdout of the command to the result object. Enable this if you need the results of the call to continue a workflow
  439. @param suppress_stderr: If true, suppress capturing the stderr stream
  440. @param cwd: Specify an optional current working directory for the execution
  441. @return: Tuple of the exit code from command line executable, the stdout (if capture_stdout is set to True), and the stderr if any
  442. """
  443. try:
  444. call_args = [self.executable_path]
  445. if isinstance(arguments, list):
  446. call_args.extend(arguments)
  447. else:
  448. call_args.append(str(arguments))
  449. logging.debug("exec(%s)", subprocess.list2cmdline(call_args))
  450. result = subprocess.run(call_args,
  451. shell=(platform.system() == 'Windows'),
  452. capture_output=capture_stdout,
  453. stderr=subprocess.DEVNULL if not capture_stdout and suppress_stderr else None,
  454. encoding='utf-8',
  455. errors='ignore',
  456. cwd=cwd)
  457. result_code = result.returncode
  458. result_stdout = result.stdout
  459. result_stderr = None if suppress_stderr else result.stderr
  460. return result_code, result_stdout, result_stderr
  461. except subprocess.CalledProcessError as err:
  462. raise LmbrCmdError(f"Error trying to call '{self.executable_path}': {str(err)}")
  463. def popen(self, arguments, cwd=None, shell=True):
  464. """
  465. Wrapper to executing calls
  466. @param arguments: Arguments to pass to ''
  467. @param capture_stdout: If true, capture the stdout of the command to the result object. Enable this if you need the results of the call to continue a workflow
  468. @param cwd: Specify an optional current working directory for the execution
  469. @return: Tuple of the exit code from command line executable, the stdout (if capture_stdout is set to True), and the stderr if any
  470. """
  471. try:
  472. call_args = [self.executable_path]
  473. if isinstance(arguments, list):
  474. call_args.extend(arguments)
  475. else:
  476. call_args.append(str(arguments))
  477. logging.debug("exec(%s)", subprocess.list2cmdline(call_args))
  478. result = subprocess.Popen(call_args,
  479. universal_newlines=True,
  480. bufsize=1,
  481. shell=shell,
  482. stdout=subprocess.PIPE,
  483. stderr=subprocess.STDOUT,
  484. encoding='utf-8',
  485. errors='ignore',
  486. cwd=cwd)
  487. return result
  488. except subprocess.CalledProcessError as err:
  489. raise LmbrCmdError(f"Error trying to call '{self.executable_path}': {str(err)}")
  490. def sync_platform_layout(platform_name, project_path, asset_mode, asset_type, layout_root):
  491. """
  492. Perform a layout sync directly on the game project for a platform, game project, asset mode, asset type
  493. @param platform_name: The platform (lower) name to sync from
  494. @param project_path: The path to project to sync to
  495. @param asset_mode: The asset mode to base the sync on
  496. @param asset_type: The asset type to base the sync on
  497. @param layout_root: The root of the layout to sync to
  498. """
  499. layout_tool.ASSET_SYNC_MODE_FUNCTION[asset_mode](target_platform=platform_name,
  500. project_path=project_path,
  501. asset_type=asset_type,
  502. warning_on_missing_assets=True,
  503. layout_target=layout_root,
  504. override_pak_folder=None,
  505. copy=False)
  506. def get_cmake_dependency_modules(build_dir_path, target, module_type):
  507. """
  508. Read a dependency registry file for a particular target and get the modules defined for that target.
  509. If the file does not exist, that means means the either target is not configured, or the special test
  510. runner setreg is not generated because it is not generated in monolithic mode
  511. :param build_dir_path: The build directory (Pathlib) base directory
  512. :param target: The name of the target the dependency file is registered for
  513. :param module_type: The module type to query for
  514. :return: List of modules that is registered for the target. An empty list if there is no dependencies for a target or the registry setting isnt set
  515. """
  516. dep_modules = []
  517. try:
  518. cmake_dep_path = build_dir_path / 'Registry' / f'cmake_dependencies.{target.lower()}.setreg'
  519. if not cmake_dep_path.is_file():
  520. return dep_modules
  521. with cmake_dep_path.open() as cmake_dep_json_file:
  522. cmake_dep_json = json.load(cmake_dep_json_file)
  523. test_module_items = cmake_dep_json['Amazon'][module_type]
  524. for _, test_module_item in test_module_items.items():
  525. module_file = test_module_item['Module']
  526. dep_modules.append(module_file)
  527. except FileNotFoundError:
  528. raise LmbrCmdError(f'{target} registry not found')
  529. except (KeyError, json.JSONDecodeError) as err:
  530. raise LmbrCmdError(f'AzTestRunner registry issue: {str(err)}')
  531. return dep_modules
  532. def get_test_module_registry(build_dir_path):
  533. """
  534. Read a test module registry file for for all test modules that are enabled for a target build directory
  535. :param build_dir_path: The target build directory (Pathlib) base directory
  536. :return: List of modules that is registered for the target. An empty list if there is no dependencies for a target or the registry setting isnt set
  537. """
  538. dep_modules = []
  539. try:
  540. unit_test_module_path = build_dir_path / 'unit_test_modules.json'
  541. with unit_test_module_path.open() as unit_test_json_file:
  542. unit_test_json = json.load(unit_test_json_file)
  543. test_module_items = unit_test_json['Amazon']
  544. for _, test_module_item in test_module_items.items():
  545. module_files = test_module_item['Modules']
  546. dep_modules.extend(module_files)
  547. except FileNotFoundError:
  548. raise LmbrCmdError(f"Unit test registry not found ('{str(unit_test_module_path)}')")
  549. except (KeyError, json.JSONDecodeError) as err:
  550. raise LmbrCmdError(f'Unit test registry file issue: {str(err)}')
  551. return dep_modules
  552. def get_validated_test_modules(test_modules, build_dir_path):
  553. """
  554. Validatate the provided test modules against all test modules
  555. :param test_modules: List of test target names
  556. :param build_dir_path: The target build directory (Pathlib) base directory
  557. :return: List of valid test modules that match the input test modules. If the input test modules is an empty list, return all valid test modules
  558. """
  559. # Collect the test modules that can be launched
  560. all_test_modules = get_test_module_registry(build_dir_path=build_dir_path)
  561. validated_test_modules = []
  562. # Validate input test targets or use all test modules if no specific test target is supplied
  563. if test_modules:
  564. assert isinstance(test_modules, list)
  565. for test_target_check in test_modules:
  566. if test_target_check not in all_test_modules:
  567. raise LmbrCmdError(f"Invalid test module {test_target_check}")
  568. if isinstance(test_target_check, list):
  569. validated_test_modules.extend(test_target_check)
  570. else:
  571. validated_test_modules.append(test_target_check)
  572. else:
  573. validated_test_modules = all_test_modules
  574. return validated_test_modules