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. logger = 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. logger.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. :param launch_ap: Bool to launch the asset processor
  62. :param configure_settings: Bool to update settings caches
  63. :return: None
  64. """
  65. # Remove existing logs and dmp files before launching for self.save_project_log_files()
  66. if os.path.exists(self.workspace.paths.project_log()):
  67. for artifact in os.listdir(self.workspace.paths.project_log()):
  68. try:
  69. artifact_ext = os.path.splitext(artifact)[1]
  70. if artifact_ext == '.dmp':
  71. os.remove(os.path.join(self.workspace.paths.project_log(), artifact))
  72. logger.info(f"Removing pre-existing artifact {artifact} from calling Launcher.setup()")
  73. # For logs, we are going to keep the file in existance and clear it to play nice with filesystem caching and
  74. # our code reading the contents of the file
  75. elif artifact_ext == '.log':
  76. open(os.path.join(self.workspace.paths.project_log(), artifact), 'w').close() # clear it
  77. logger.debug(f"Clearing pre-existing artifact {artifact} from calling Launcher.setup()")
  78. except PermissionError:
  79. logger.warning(f'Unable to remove artifact: {artifact}, skipping.')
  80. pass
  81. # In case this is the first run, we will create default logs to prevent the logmonitor from not finding the file
  82. os.makedirs(self.workspace.paths.project_log(), exist_ok=True)
  83. default_logs = ["Editor.log", "Game.log"]
  84. for default_log in default_logs:
  85. default_log_path = os.path.join(self.workspace.paths.project_log(), default_log)
  86. if not os.path.exists(default_log_path):
  87. open(default_log_path, 'w').close() # Create it
  88. # Wait for the AssetProcessor to be open.
  89. if launch_ap:
  90. logger.debug('AssetProcessor started from calling Launcher.setup()')
  91. self.workspace.asset_processor.start(connect_to_ap=True, connection_timeout=10) # verify connection
  92. self.workspace.asset_processor.wait_for_idle()
  93. def backup_settings(self):
  94. """
  95. Perform settings backup, storing copies of bootstrap, platform and user settings in the workspace's temporary
  96. directory. Must be called after settings have been generated, in case they don't exist.
  97. These backups will be lost after the workspace is torn down.
  98. :return: None
  99. """
  100. backup_path = self.workspace.settings.get_temp_path()
  101. logger.debug(f"Performing automatic backup of bootstrap, platform and user settings in path {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. --regset="<key>=<value> arguments via the command line
  107. :return: None
  108. """
  109. logger.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. logger.debug(f"Restoring backup of bootstrap, platform and user settings in path {backup_path}")
  118. def teardown(self):
  119. """
  120. Perform teardown of this launcher, undoing actions taken by calling setup()
  121. Subclasses should call its parent's teardown() after performing its own teardown.
  122. :return: None
  123. """
  124. self.workspace.asset_processor.stop()
  125. def save_project_log_files(self):
  126. # type: () -> None
  127. """
  128. Moves all .dmp and .log files from the project log folder into the artifact manager's destination
  129. :return: None
  130. """
  131. # A healthy large limit boundary
  132. amount_of_log_name_collisions = 100
  133. if os.path.exists(self.workspace.paths.project_log()):
  134. for artifact in os.listdir(self.workspace.paths.project_log()):
  135. if artifact.endswith('.dmp') or artifact.endswith('.log'):
  136. self.workspace.artifact_manager.save_artifact(
  137. os.path.join(self.workspace.paths.project_log(), artifact),
  138. amount=amount_of_log_name_collisions)
  139. def binary_path(self):
  140. """
  141. Return this launcher's path to its binary file (exe, app, apk, etc).
  142. Only required if the platform supports it.
  143. :return: Complete path to the binary (if supported)
  144. """
  145. raise NotImplementedError("There is no binary file for this launcher")
  146. def start(self, backupFiles=True, launch_ap=None, configure_settings=True):
  147. """
  148. Automatically prepare and launch the application
  149. When called using "with launcher.start():" it will automatically call stop() when block exits
  150. Subclasses should avoid overriding this method
  151. :return: Application wrapper for context management, not intended to be called directly
  152. """
  153. return _Application(self, backupFiles, launch_ap=launch_ap, configure_settings=configure_settings)
  154. def _start_impl(self, backupFiles = True, launch_ap=None, configure_settings=True):
  155. """
  156. Implementation of start(), intended to be called via context manager in _Application
  157. :param backupFiles: Bool to backup settings files
  158. :return None:
  159. """
  160. self.setup(backupFiles=backupFiles, launch_ap=launch_ap, configure_settings=configure_settings)
  161. self.launch()
  162. def stop(self):
  163. """
  164. Terminate the application and perform automated teardown, the opposite of calling start()
  165. Called automatically when using "with launcher.start():"
  166. :return None:
  167. """
  168. self._kill()
  169. self.ensure_stopped()
  170. self.teardown()
  171. def is_alive(self):
  172. """
  173. Return whether the launcher is alive.
  174. :return: True if alive, False otherwise
  175. """
  176. raise NotImplementedError("is_alive is not implemented")
  177. def launch(self):
  178. """
  179. Launch the game, this method can perform a quick verification after launching, but it is not required.
  180. :return None:
  181. """
  182. raise NotImplementedError("Launch is not implemented")
  183. def _kill(self):
  184. """
  185. Force stop the launcher.
  186. :return None:
  187. """
  188. raise NotImplementedError("Kill is not implemented")
  189. def package(self):
  190. """
  191. Performs actions required to create a launcher-package to be deployed for the given target.
  192. This command will package without deploying.
  193. This function is not applicable for PC, Mac, and ios.
  194. Subclasses should override only if needed. The default behavior is to do nothing.
  195. :return None:
  196. """
  197. logger.debug("No-op package requested")
  198. pass
  199. def wait(self, timeout=30):
  200. """
  201. Wait for the launcher to end gracefully, raises exception if process is still running after specified timeout
  202. """
  203. ly_test_tools.environment.waiter.wait_for(
  204. lambda: not self.is_alive(),
  205. exc=ly_test_tools.launchers.exceptions.WaitTimeoutError(f"Application is unexpectedly still active after "
  206. f"timeout of {timeout} seconds"),
  207. timeout=timeout
  208. )
  209. def ensure_stopped(self, timeout=30):
  210. """
  211. Wait for the launcher to end gracefully, if the process is still running after the specified timeout, it is
  212. killed by calling the kill() method.
  213. :param timeout: Timeout in seconds to wait for launcher to be killed
  214. :return None:
  215. """
  216. try:
  217. ly_test_tools.environment.waiter.wait_for(
  218. lambda: not self.is_alive(),
  219. exc=ly_test_tools.launchers.exceptions.TeardownError(f"Application is unexpectedly still active after "
  220. f"timeout of {timeout} seconds"),
  221. timeout=timeout
  222. )
  223. except ly_test_tools.launchers.exceptions.TeardownError:
  224. self._kill()
  225. def get_device_config(self, config_file, device_section, device_key):
  226. """
  227. Takes an .ini config file path, .ini section name, and key for the value to search
  228. inside of that .ini section. Returns a string representing a device identifier, i.e. an IP.
  229. :param config_file: string representing the file path for the config ini file.
  230. default is '~/ly_test_tools/devices.ini'
  231. :param device_section: string representing the section to search in the ini file.
  232. :param device_key: string representing the key to search in device_section.
  233. :return: value held inside of 'device_key' from 'device_section' section,
  234. otherwise raises a SetupError.
  235. """
  236. config_dict = self._config_ini_to_dict(config_file)
  237. section_dict = {}
  238. device_value = ''
  239. # Verify 'device_section' and 'device_key' are valid, then return value inside 'device_key'.
  240. try:
  241. section_dict = config_dict[device_section]
  242. except (AttributeError, KeyError, ValueError) as err:
  243. problem = ly_test_tools.launchers.exceptions.SetupError(
  244. f"Could not find device section '{device_section}' from ini file: '{config_file}'")
  245. six.raise_from(problem, err)
  246. try:
  247. device_value = section_dict[device_key]
  248. except (AttributeError, KeyError, ValueError) as err:
  249. problem = ly_test_tools.launchers.exceptions.SetupError(
  250. f"Could not find device key '{device_key}' "
  251. f"from section '{device_section}' in ini file: '{config_file}'")
  252. six.raise_from(problem, err)
  253. return device_value
  254. class _Application(object):
  255. """
  256. Context-manager for opening an application, enables using both "launcher.start()" and "with launcher.start()"
  257. """
  258. def __init__(self, launcher, backupFiles = True, launch_ap=None, configure_settings=True):
  259. """
  260. Called during both "launcher.start()" and "with launcher.start()"
  261. :param launcher: launcher-object to manage
  262. :return None:
  263. """
  264. self.launcher = launcher
  265. launcher._start_impl(backupFiles, launch_ap, configure_settings)
  266. def __enter__(self):
  267. """
  268. PEP-343 Context manager begin-hook
  269. Runs at the start of "with launcher.start()"
  270. :return None:
  271. """
  272. return self
  273. def __exit__(self, exc_type, exc_val, exc_tb):
  274. """
  275. PEP-343 Context manager end-hook
  276. Runs at the end of "with launcher.start()" block
  277. :return None:
  278. """
  279. self.launcher.stop()