bundler_batch_setup_fixture.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  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. Fixture for helping Asset Bundler Batch tests
  6. """
  7. import os
  8. import pytest
  9. from xml.etree import ElementTree as ET
  10. import tempfile as TF
  11. import re
  12. import subprocess
  13. import logging
  14. import zipfile
  15. from typing import Any, Dict, List
  16. from assetpipeline.ap_fixtures.asset_processor_fixture import asset_processor
  17. from assetpipeline.ap_fixtures.timeout_option_fixture import timeout_option_fixture as timeout
  18. from assetpipeline.ap_fixtures.ap_config_backup_fixture import ap_config_backup_fixture as config_backup
  19. from assetpipeline.ap_fixtures.ap_setup_fixture import ap_setup_fixture
  20. import ly_test_tools.environment.file_system as fs
  21. import ly_test_tools.o3de.pipeline_utils as utils
  22. from ly_test_tools.o3de.asset_processor import ASSET_PROCESSOR_PLATFORM_MAP
  23. logger = logging.getLogger(__name__)
  24. @pytest.fixture
  25. @pytest.mark.usefixtures("config_backup")
  26. @pytest.mark.usefixtures("asset_processor")
  27. @pytest.mark.usefixtures("ap_setup_fixture")
  28. def bundler_batch_setup_fixture(request, workspace, ap_setup_fixture, asset_processor) -> Any:
  29. def get_all_platforms() -> list[str]:
  30. """Helper: This function generates a list of all platforms to be built for testing Asset Bundler."""
  31. ALL_PLATFORMS = []
  32. for _, value in ASSET_PROCESSOR_PLATFORM_MAP.items():
  33. ALL_PLATFORMS.append(value)
  34. return ALL_PLATFORMS
  35. def platforms_to_build() -> list | list[str]:
  36. """Helper: Gets the platforms to build for bundle tests."""
  37. platforms = request.config.getoption("--bundle_platforms")
  38. if platforms is not None:
  39. # Remove whitespace and split at commas if platforms provided
  40. platforms = [platform.strip() for platform in platforms.split(",")]
  41. else:
  42. # No commandline argument provided, default to mac and pc
  43. platforms = ["pc", "mac", "linux"]
  44. return platforms
  45. def setup_temp_workspace() -> None:
  46. """Helper: Sets up the temp workspace for asset bundling tests."""
  47. asset_processor.create_temp_asset_root()
  48. def setup_temp_dir() -> Any:
  49. """Helper: Sets up a temp directory for use in bundling tests that can't use the temp workspace."""
  50. tempDir = os.path.join(TF.gettempdir(), "AssetBundlerTempDirectory")
  51. if os.path.exists(tempDir):
  52. fs.delete([tempDir], True, True)
  53. if not os.path.exists(tempDir):
  54. os.mkdir(tempDir)
  55. return tempDir
  56. class WorkSpaceBundlerBatchFixture:
  57. """
  58. Houses useful variables and functions for running Asset Bundler Batch Tests
  59. """
  60. def __init__(self):
  61. self.bin_dir = workspace.paths.build_directory()
  62. self.bundler_batch = os.path.join(self.bin_dir, "AssetBundlerBatch")
  63. self.seed_list_file_name = "testSeedListFile.seed"
  64. self.asset_info_file_name = "assetFileInfo.assetlist"
  65. self.bundle_file_name = "bundle.pak"
  66. self.bundle_settings_file_name = "bundleSettingsFile.bundlesettings"
  67. self.workspace = workspace
  68. self.platforms = platforms_to_build()
  69. self.platforms_as_string = ",".join(self.platforms)
  70. # Useful sizes
  71. self.max_bundle_size_in_mib = 35
  72. self.number_of_bytes_in_mib = 1024 * 1024
  73. # Checks whether or not the fixture was parametrized to use the temp workspace. Defaults to True.
  74. self.use_temp_workspace = request.param if hasattr(request, "param") else True
  75. if self.use_temp_workspace:
  76. setup_temp_workspace()
  77. self.project_path = os.path.join(asset_processor.temp_asset_root(), workspace.project)
  78. self.cache = asset_processor.temp_project_cache_path()
  79. self.seed_list_file = os.path.join(self.project_path, self.seed_list_file_name)
  80. self.asset_info_file_request = os.path.join(self.project_path, self.asset_info_file_name)
  81. self.bundle_settings_file_request = os.path.join(self.project_path, self.bundle_settings_file_name)
  82. self.bundle_file = os.path.join(self.project_path, self.bundle_file_name)
  83. self.asset_info_file_result = os.path.join(
  84. self.project_path, self.platform_file_name(self.asset_info_file_name,
  85. workspace.asset_processor_platform)
  86. )
  87. self.bundle_settings_file_result = os.path.join(
  88. self.project_path, self.platform_file_name(self.bundle_settings_file_name,
  89. workspace.asset_processor_platform)
  90. )
  91. else:
  92. self.project_path = workspace.paths.project()
  93. self.cache = workspace.paths.project_cache()
  94. self.test_dir = setup_temp_dir()
  95. self.seed_list_file = os.path.join(self.test_dir, self.seed_list_file_name)
  96. self.asset_info_file_request = os.path.join(self.test_dir, self.asset_info_file_name)
  97. self.bundle_settings_file_request = os.path.join(self.test_dir, self.bundle_settings_file_name)
  98. self.bundle_file = os.path.join(self.test_dir, self.bundle_file_name)
  99. self.asset_info_file_result = os.path.join(
  100. self.test_dir, self.platform_file_name(self.asset_info_file_name,
  101. workspace.asset_processor_platform)
  102. )
  103. self.bundle_settings_file_result = os.path.join(
  104. self.test_dir, self.platform_file_name(self.bundle_settings_file_name,
  105. workspace.asset_processor_platform)
  106. )
  107. @staticmethod
  108. def call_asset_bundler(arg_list):
  109. """
  110. Prints out the call to asset bundler, so if an error occurs, the commands that were run can be repeated.
  111. """
  112. logger.info(f"{' '.join(f'arg_list{x}' for x in arg_list)}")
  113. try:
  114. output = subprocess.check_output(arg_list).decode()
  115. return True, output
  116. except subprocess.CalledProcessError as e:
  117. output = e.output.decode('utf-8')
  118. logger.error(f"AssetBundlerBatch called with args {arg_list} returned error {e} with output {output}")
  119. return False, output
  120. except FileNotFoundError as e:
  121. logger.error(f"File Not Found - Failed to call AssetBundlerBatch with args {arg_list} with error {e}")
  122. raise e
  123. def call_bundlerbatch(self, **kwargs: Dict[str, str]) -> tuple[bool, str] | tuple[bool, Any]:
  124. """Helper function for calling assetbundlerbatch with no sub-command"""
  125. cmd = [self.bundler_batch]
  126. return self.call_asset_bundler(self._append_arguments(cmd, kwargs))
  127. def call_seeds(self, **kwargs: Dict[str, str]) -> tuple[bool, str] | tuple[bool, Any]:
  128. """Helper function for calling assetbundlerbatch with 'seeds' sub-command"""
  129. cmd = [self.bundler_batch, "seeds"]
  130. return self.call_asset_bundler(self._append_arguments(cmd, kwargs))
  131. def call_assetLists(self, **kwargs: Dict) -> tuple[bool, str] | tuple[bool, Any]:
  132. """Helper function for calling assetbundlerbatch with 'assetLists' sub-command"""
  133. cmd = [self.bundler_batch, "assetLists"]
  134. return self.call_asset_bundler(self._append_arguments(cmd, kwargs))
  135. def call_comparisonRules(self, **kwargs: Dict) -> tuple[bool, str] | tuple[bool, Any]:
  136. """Helper function for calling assetbundlerbatch with 'comparisonRules' sub-command"""
  137. cmd = [self.bundler_batch, "comparisonRules"]
  138. return self.call_asset_bundler(self._append_arguments(cmd, kwargs))
  139. def call_compare(self, **kwargs: Dict) -> tuple[bool, str] | tuple[bool, Any]:
  140. """Helper function for calling assetbundlerbatch with 'compare' sub-command"""
  141. cmd = [self.bundler_batch, "compare"]
  142. return self.call_asset_bundler(self._append_arguments(cmd, kwargs))
  143. def call_bundleSettings(self, **kwargs: Dict) -> tuple[bool, str] | tuple[bool, Any]:
  144. """Helper function for calling assetbundlerbatch with 'bundleSettings' sub-command"""
  145. cmd = [self.bundler_batch, "bundleSettings"]
  146. return self.call_asset_bundler(self._append_arguments(cmd, kwargs))
  147. def call_bundles(self, **kwargs: Dict) -> tuple[bool, str] | tuple[bool, Any]:
  148. """Helper function for calling assetbundlerbatch with 'bundles' sub-command"""
  149. cmd = [self.bundler_batch, "bundles"]
  150. return self.call_asset_bundler(self._append_arguments(cmd, kwargs))
  151. def call_bundleSeed(self, **kwargs: Dict) -> tuple[bool, str] | tuple[bool, Any]:
  152. """Helper function for calling assetbundlerbatch with 'bundleSeed' sub-command"""
  153. cmd = [self.bundler_batch, "bundleSeed"]
  154. return self.call_asset_bundler(self._append_arguments(cmd, kwargs))
  155. def _append_arguments(self, cmd: List[str], kwargs: Dict, append_defaults: bool = True) -> List[str]:
  156. """Appends and returns all keyword arguments to the list of string [cmd]"""
  157. for key, value in kwargs.items():
  158. if not value:
  159. cmd.append(f"--{key}")
  160. else:
  161. if type(value) != list:
  162. cmd.append(f"--{key}={value}")
  163. else:
  164. for item in value:
  165. cmd.append(f"--{key}={item}")
  166. if append_defaults:
  167. cmd.append(f"--project-path={self.project_path}")
  168. return cmd
  169. @staticmethod
  170. def get_seed_relative_paths(seed_file: str) -> str:
  171. """Iterates all asset relative paths in the [seed_file]."""
  172. assert seed_file.endswith(".seed"), f"file {seed_file} is not a seed file"
  173. # Get value from all XML nodes who are grandchildren of all Class tags and have
  174. # a field attr. equal to "pathHint"
  175. for node in ET.parse(seed_file).getroot().findall(r"./Class/Class/*[@field='pathHint']"):
  176. yield node.attrib["value"]
  177. @staticmethod
  178. def get_seed_relative_paths_for_platform(seed_file: str, platform_flags: int) -> list[str]:
  179. """Iterates all asset relative paths in the [seed_file] which match the platform flags"""
  180. assert seed_file.endswith(".seed"), f"file {seed_file} is not a seed file"
  181. # Get value from all XML nodes who are grandchildren of all Class tags and have
  182. # a field attr. equal to "pathHint"
  183. seedFileListContents = []
  184. data = ET.parse(seed_file)
  185. root = data.getroot()
  186. seedFileRootNode = root.find("Class")
  187. for seedFileInfoNode in seedFileRootNode.findall("*"):
  188. if (int(seedFileInfoNode.find('./Class[@field="platformFlags"]').attrib["value"]) & platform_flags):
  189. pathHint = seedFileInfoNode.find('./Class[@field="pathHint"]').attrib["value"]
  190. seedFileListContents.append(pathHint)
  191. return seedFileListContents
  192. @staticmethod
  193. def get_asset_relative_paths(asset_list_file: str) -> str:
  194. """Iterates all asset relative paths in the [asset_list_file]."""
  195. assert asset_list_file.endswith(".assetlist"), f"file {asset_list_file} is not an assetlist file"
  196. # Get value from all XML nodes who are great-grandchildren of all Class tags and have
  197. # a field attr. equal to "assetRelativePath"
  198. for node in (ET.parse(asset_list_file).getroot().findall(
  199. r"./Class/Class/Class/*[@field='assetRelativePath']")):
  200. yield node.attrib["value"]
  201. @staticmethod
  202. def get_dependent_bundle_names(manifest_file: str) -> str:
  203. """Iterates all dependent bundle names in the [manifest_file]"""
  204. assert manifest_file.endswith(".xml"), f"File {manifest_file} does not have an XML extension"
  205. # Get value from all XML nodes whose parent field attr. is "DependentBundleNames" and whose tag is Class
  206. for node in ET.parse(manifest_file).getroot().findall(r".//*[@field='DependentBundleNames']/Class"):
  207. yield node.attrib["value"]
  208. @staticmethod
  209. def platform_file_name(file_name: str, platform: str) -> str:
  210. """Converts the standard [file_name] to a platform specific file name"""
  211. split = file_name.split(".", 1)
  212. platform_name = platform if platform in ASSET_PROCESSOR_PLATFORM_MAP.values() else ASSET_PROCESSOR_PLATFORM_MAP.get(platform)
  213. if not platform_name:
  214. logger.warning(f"platform {platform} not recognized. File name could not be generated")
  215. return file_name
  216. return f'{split[0]}_{platform_name}.{split[1]}'
  217. @staticmethod
  218. def extract_file_content(bundle_file: str, file_name_to_extract: str) -> bytes:
  219. """Extract the contents of a single file from a bundle as a ByteString."""
  220. with zipfile.ZipFile(bundle_file) as bundle_zip:
  221. with bundle_zip.open(file_name_to_extract, "r") as extracted_file:
  222. return extracted_file.read()
  223. @staticmethod
  224. def get_crc_of_files_in_archive(archive_name: str) -> Dict[str, int]:
  225. """
  226. Extracts the CRC-32 'checksum' for all files in the archive as dictionary.
  227. The returned dictionary will have:
  228. key - filename
  229. value - the crc checksum for that file
  230. """
  231. file_crcs = {}
  232. zf = zipfile.ZipFile(archive_name)
  233. for info in zf.infolist():
  234. file_crcs[info.filename] = info.CRC
  235. return file_crcs
  236. @staticmethod
  237. def get_platform_flag(platform_name: str) -> int:
  238. """ Helper to fetch the platform flag from a provided platform name. """
  239. platform_flags = {
  240. "pc": 1,
  241. "android": 2,
  242. "ios": 4,
  243. "mac": 8,
  244. "server": 128
  245. }
  246. if platform_name in platform_flags:
  247. return platform_flags.get(platform_name)
  248. raise ValueError(f"{platform_name} not found within expected platform flags. "
  249. f"Expected: {platform_flags.keys()}")
  250. def extract_and_check(self, extract_dir: str, bundle_file: str) -> None:
  251. """
  252. Helper function to extract the manifest from a bundle
  253. and validate against the actual files in the bundle
  254. """
  255. # Ensure that the parent bundle was created
  256. assert os.path.isfile(bundle_file), f"{bundle_file} was not created by the 'bundles' call"
  257. os.mkdir(extract_dir)
  258. with zipfile.ZipFile(bundle_file) as bundle_zip:
  259. bundle_zip.extract("manifest.xml", path=extract_dir)
  260. # Populate the bundle file paths
  261. dependent_bundle_name = [bundle_file]
  262. manifest_file_path = os.path.join(extract_dir, "manifest.xml")
  263. for bundle_name in self.get_dependent_bundle_names(manifest_file_path):
  264. dependent_bundle_name.append(os.path.join(self.test_dir, bundle_name))
  265. # relative asset paths
  266. assets_from_file = []
  267. for rel_path in self.get_asset_relative_paths(self.asset_info_file_result):
  268. assets_from_file.append(os.path.normpath(rel_path))
  269. expected_size = self.max_bundle_size_in_mib * self.number_of_bytes_in_mib
  270. # extract all files from the bundles
  271. for bundle in dependent_bundle_name:
  272. file_info = os.stat(bundle)
  273. # Verify that the size of all bundles is less than the max size specified
  274. assert file_info.st_size <= expected_size, \
  275. f"file_info.st_size {file_info.st_size} for bundle {bundle} was expected to be smaller than {expected_size}"
  276. with zipfile.ZipFile(bundle) as bundle_zip:
  277. bundle_zip.extractall(extract_dir)
  278. ignore_list = ["assetCatalog.bundle", "manifest.xml", "DeltaCatalog.xml"]
  279. assets_in_disk = utils.get_relative_file_paths(extract_dir, ignore_list=ignore_list)
  280. # Ensure that all assets were present in the bundles
  281. assert sorted(assets_from_file) == sorted(assets_in_disk)
  282. @staticmethod
  283. def _get_platform_flags() -> Dict[str, int]:
  284. """
  285. Extract platform numeric values from the file that declares them (PlatformDefaults.h):
  286. Platform Flags are defined in the C header file, where ORDER MATTERS.
  287. Dynamically parse them from the file's enum and calculate their integer value at run-time.
  288. Note: the platform's are bit flags, so their values are powers of 2: 1 << position_in_file
  289. """
  290. platform_declaration_file = os.path.join(
  291. workspace.paths.engine_root(),
  292. "Code",
  293. "Framework",
  294. "AzCore",
  295. "AzCore",
  296. "PlatformId",
  297. "PlatformDefaults.h",
  298. )
  299. platform_values = {} # Dictionary to store platform flags. {<platform>: int}
  300. counter = 1 # Store current platform flag value
  301. get_platform = re.compile(r"^\s+(\w+),")
  302. start_gathering = False
  303. with open(platform_declaration_file, "r") as platform_file:
  304. for line in platform_file.readlines():
  305. if start_gathering:
  306. if "NumPlatforms" in line:
  307. break # NumPlatforms is the last
  308. if start_gathering:
  309. result = get_platform.match(line) # Try the regex
  310. if result:
  311. platform_values[result.group(1).replace("_ID", "").lower()] = counter
  312. counter = counter << 1
  313. elif "(Invalid, -1)" in line: # The line right before the first platform
  314. start_gathering = True
  315. return platform_values
  316. def teardown(self) -> None:
  317. """Destroys the temporary directory used in this test"""
  318. if self.use_temp_workspace:
  319. fs.delete([asset_processor.temp_asset_root()], True, True)
  320. else:
  321. fs.delete([self.test_dir], True, True)
  322. def __getitem__(self, item: str) -> str:
  323. """Get Item overload to use the object like a dictionary.
  324. Implemented so this fixture "looks and feels" like other AP fixtures that return dictionaries"""
  325. return self.__dict__[str(item)]
  326. # End class BundlerBatchFixture
  327. bundler_batch_fixture = WorkSpaceBundlerBatchFixture()
  328. request.addfinalizer(bundler_batch_fixture.teardown)
  329. bundler_batch_fixture.platform_values = bundler_batch_fixture._get_platform_flags()
  330. return bundler_batch_fixture # Return the fixture