base.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  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. Basic interface to interact with lumberyard launcher
  6. """
  7. import logging
  8. import os
  9. from configparser import ConfigParser
  10. import six
  11. import ly_test_tools.launchers.exceptions
  12. import ly_test_tools.environment.process_utils
  13. import ly_test_tools.environment.waiter
  14. log = logging.getLogger(__name__)
  15. class Launcher(object):
  16. def __init__(self, workspace, args):
  17. # type: (ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager, List[str]) -> None
  18. """
  19. Constructor for a generic launcher, requires a reference to the containing workspace and a list of arguments
  20. to pass to the game during launch.
  21. :param workspace: Workspace containing the launcher
  22. :param args: list of arguments passed to the game during launch
  23. """
  24. log.debug(f"Initializing launcher for workspace '{workspace}' with args '{args}'")
  25. self.workspace = workspace # type: ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager
  26. if args:
  27. if isinstance(args, list):
  28. self.args = args
  29. else:
  30. raise TypeError(f"Launcher args must be provided as a list, received: '{type(args)}'")
  31. else:
  32. self.args = []
  33. def _config_ini_to_dict(self, config_file):
  34. """
  35. Converts an .ini config file to a dict of dicts, then returns it.
  36. :param config_file: string representing the file path to the .ini file.
  37. :return: dict of dicts containing the section & keys from the .ini file,
  38. otherwise raises a SetupError.
  39. """
  40. config_dict = {}
  41. user_profile_directory = os.path.expanduser('~').replace(os.sep, '/')
  42. if not os.path.exists(config_file):
  43. raise ly_test_tools.launchers.exceptions.SetupError(
  44. f'Default file path not found: "{user_profile_directory}/ly_test_tools/devices.ini", '
  45. f'got path: "{config_file}" instead. '
  46. f'Please create the following file: "{user_profile_directory}/ly_test_tools/devices.ini" manually. '
  47. f'Add device IP/ID info inside each section as well.\n'
  48. 'See ~/engine_root/dev/Tools/LyTestTools/README.txt for more info.')
  49. config = ConfigParser()
  50. config.read(config_file)
  51. for section in config.sections():
  52. config_dict[section] = dict(config.items(section))
  53. return config_dict
  54. def setup(self, backupFiles=True, launch_ap=True, configure_settings=True):
  55. """
  56. Perform setup of this launcher, must be called before launching.
  57. Subclasses should call its parent's setup() before calling its own code, unless it changes configuration files
  58. For testing mobile or console devices, make sure you populate the config file located at:
  59. ~/ly_test_tools/devices.ini (a.k.a. %USERPROFILE%/ly_test_tools/devices.ini)
  60. :param backupFiles: Bool to backup setup files
  61. :return: None
  62. """
  63. # Remove existing logs and dmp files before launching for self.save_project_log_files()
  64. if os.path.exists(self.workspace.paths.project_log()):
  65. for artifact in os.listdir(self.workspace.paths.project_log()):
  66. try:
  67. artifact_ext = os.path.splitext(artifact)[1]
  68. if artifact_ext == '.dmp':
  69. os.remove(os.path.join(self.workspace.paths.project_log(), artifact))
  70. log.info(f"Removing pre-existing artifact {artifact} from calling Launcher.setup()")
  71. # For logs, we are going to keep the file in existance and clear it to play nice with filesystem caching and
  72. # our code reading the contents of the file
  73. elif artifact_ext == '.log':
  74. open(os.path.join(self.workspace.paths.project_log(), artifact), 'w').close() # clear it
  75. log.info(f"Clearing pre-existing artifact {artifact} from calling Launcher.setup()")
  76. except PermissionError:
  77. log.warn(f'Unable to remove artifact: {artifact}, skipping.')
  78. pass
  79. # In case this is the first run, we will create default logs to prevent the logmonitor from not finding the file
  80. os.makedirs(self.workspace.paths.project_log(), exist_ok=True)
  81. default_logs = ["Editor.log", "Game.log"]
  82. for default_log in default_logs:
  83. default_log_path = os.path.join(self.workspace.paths.project_log(), default_log)
  84. if not os.path.exists(default_log_path):
  85. open(default_log_path, 'w').close() # Create it
  86. # Wait for the AssetProcessor to be open.
  87. if launch_ap:
  88. self.workspace.asset_processor.start(connect_to_ap=True, connection_timeout=10) # verify connection
  89. self.workspace.asset_processor.wait_for_idle()
  90. log.debug('AssetProcessor started from calling Launcher.setup()')
  91. def backup_settings(self):
  92. """
  93. Perform settings backup, storing copies of bootstrap, platform and user settings in the workspace's temporary
  94. directory. Must be called after settings have been generated, in case they don't exist.
  95. These backups will be lost after the workspace is torn down.
  96. :return: None
  97. """
  98. backup_path = self.workspace.settings.get_temp_path()
  99. log.debug(f"Performing automatic backup of bootstrap, platform and user settings in path {backup_path}")
  100. self.workspace.settings.backup_platform_settings(backup_path)
  101. self.workspace.settings.backup_shader_compiler_settings(backup_path)
  102. def configure_settings(self):
  103. """
  104. Perform settings configuration, must be called after a backup of settings has been created with
  105. backup_settings(). Preferred ways to modify settings are:
  106. self.workspace.settings.modify_platform_setting()
  107. :return: None
  108. """
  109. log.debug("No-op settings configuration requested")
  110. pass
  111. def restore_settings(self):
  112. """
  113. Restores the settings backups created with backup_settings(). Must be called during teardown().
  114. :return: None
  115. """
  116. backup_path = self.workspace.settings.get_temp_path()
  117. log.debug(f"Restoring backup of bootstrap, platform and user settings in path {backup_path}")
  118. self.workspace.settings.restore_platform_settings(backup_path)
  119. self.workspace.settings.restore_shader_compiler_settings(backup_path)
  120. def teardown(self):
  121. """
  122. Perform teardown of this launcher, undoing actions taken by calling setup()
  123. Subclasses should call its parent's teardown() after performing its own teardown.
  124. :return: None
  125. """
  126. self.workspace.asset_processor.stop()
  127. self.save_project_log_files()
  128. def save_project_log_files(self):
  129. # type: () -> None
  130. """
  131. Moves all .dmp and .log files from the project log folder into the artifact manager's destination
  132. :return: None
  133. """
  134. # A healthy large limit boundary
  135. amount_of_log_name_collisions = 100
  136. if os.path.exists(self.workspace.paths.project_log()):
  137. for artifact in os.listdir(self.workspace.paths.project_log()):
  138. if artifact.endswith('.dmp') or artifact.endswith('.log'):
  139. self.workspace.artifact_manager.save_artifact(
  140. os.path.join(self.workspace.paths.project_log(), artifact),
  141. amount=amount_of_log_name_collisions)
  142. def binary_path(self):
  143. """
  144. Return this launcher's path to its binary file (exe, app, apk, etc).
  145. Only required if the platform supports it.
  146. :return: Complete path to the binary (if supported)
  147. """
  148. raise NotImplementedError("There is no binary file for this launcher")
  149. def start(self, backupFiles=True, launch_ap=None, configure_settings=True):
  150. """
  151. Automatically prepare and launch the application
  152. When called using "with launcher.start():" it will automatically call stop() when block exits
  153. Subclasses should avoid overriding this method
  154. :return: Application wrapper for context management, not intended to be called directly
  155. """
  156. return _Application(self, backupFiles, launch_ap=launch_ap, configure_settings=configure_settings)
  157. def _start_impl(self, backupFiles = True, launch_ap=None, configure_settings=True):
  158. """
  159. Implementation of start(), intended to be called via context manager in _Application
  160. :param backupFiles: Bool to backup settings files
  161. :return None:
  162. """
  163. self.setup(backupFiles=backupFiles, launch_ap=launch_ap, configure_settings=configure_settings)
  164. self.launch()
  165. def stop(self):
  166. """
  167. Terminate the application and perform automated teardown, the opposite of calling start()
  168. Called automatically when using "with launcher.start():"
  169. :return None:
  170. """
  171. self.kill()
  172. self.ensure_stopped()
  173. self.teardown()
  174. def is_alive(self):
  175. """
  176. Return whether the launcher is alive.
  177. :return: True if alive, False otherwise
  178. """
  179. raise NotImplementedError("is_alive is not implemented")
  180. def launch(self):
  181. """
  182. Launch the game, this method can perform a quick verification after launching, but it is not required.
  183. :return None:
  184. """
  185. raise NotImplementedError("Launch is not implemented")
  186. def kill(self):
  187. """
  188. Force stop the launcher.
  189. :return None:
  190. """
  191. raise NotImplementedError("Kill is not implemented")
  192. def package(self):
  193. """
  194. Performs actions required to create a launcher-package to be deployed for the given target.
  195. This command will package without deploying.
  196. This function is not applicable for PC, Mac, and ios.
  197. Subclasses should override only if needed. The default behavior is to do nothing.
  198. :return None:
  199. """
  200. log.debug("No-op package requested")
  201. pass
  202. def wait(self, timeout=30):
  203. """
  204. Wait for the launcher to end gracefully, raises exception if process is still running after specified timeout
  205. """
  206. ly_test_tools.environment.waiter.wait_for(
  207. lambda: not self.is_alive(),
  208. exc=ly_test_tools.launchers.exceptions.WaitTimeoutError("Application is unexpectedly still active"),
  209. timeout=timeout
  210. )
  211. def ensure_stopped(self, timeout=30):
  212. """
  213. Wait for the launcher to end gracefully, if the process is still running after the specified timeout, it is
  214. killed by calling the kill() method.
  215. :param timeout: Timeout in seconds to wait for launcher to be killed
  216. :return None:
  217. """
  218. try:
  219. ly_test_tools.environment.waiter.wait_for(
  220. lambda: not self.is_alive(),
  221. exc=ly_test_tools.launchers.exceptions.TeardownError("Application is unexpectedly still active"),
  222. timeout=timeout
  223. )
  224. except ly_test_tools.launchers.exceptions.TeardownError:
  225. self.kill()
  226. def get_device_config(self, config_file, device_section, device_key):
  227. """
  228. Takes an .ini config file path, .ini section name, and key for the value to search
  229. inside of that .ini section. Returns a string representing a device identifier, i.e. an IP.
  230. :param config_file: string representing the file path for the config ini file.
  231. default is '~/ly_test_tools/devices.ini'
  232. :param device_section: string representing the section to search in the ini file.
  233. :param device_key: string representing the key to search in device_section.
  234. :return: value held inside of 'device_key' from 'device_section' section,
  235. otherwise raises a SetupError.
  236. """
  237. config_dict = self._config_ini_to_dict(config_file)
  238. section_dict = {}
  239. device_value = ''
  240. # Verify 'device_section' and 'device_key' are valid, then return value inside 'device_key'.
  241. try:
  242. section_dict = config_dict[device_section]
  243. except (AttributeError, KeyError, ValueError) as err:
  244. problem = ly_test_tools.launchers.exceptions.SetupError(
  245. f"Could not find device section '{device_section}' from ini file: '{config_file}'")
  246. six.raise_from(problem, err)
  247. try:
  248. device_value = section_dict[device_key]
  249. except (AttributeError, KeyError, ValueError) as err:
  250. problem = ly_test_tools.launchers.exceptions.SetupError(
  251. f"Could not find device key '{device_key}' "
  252. f"from section '{device_section}' in ini file: '{config_file}'")
  253. six.raise_from(problem, err)
  254. return device_value
  255. class _Application(object):
  256. """
  257. Context-manager for opening an application, enables using both "launcher.start()" and "with launcher.start()"
  258. """
  259. def __init__(self, launcher, backupFiles = True, launch_ap=None, configure_settings=True):
  260. """
  261. Called during both "launcher.start()" and "with launcher.start()"
  262. :param launcher: launcher-object to manage
  263. :return None:
  264. """
  265. self.launcher = launcher
  266. launcher._start_impl(backupFiles, launch_ap, configure_settings)
  267. def __enter__(self):
  268. """
  269. PEP-343 Context manager begin-hook
  270. Runs at the start of "with launcher.start()"
  271. :return None:
  272. """
  273. return self
  274. def __exit__(self, exc_type, exc_val, exc_tb):
  275. """
  276. PEP-343 Context manager end-hook
  277. Runs at the end of "with launcher.start()" block
  278. :return None:
  279. """
  280. self.launcher.stop()