Initial commit

This commit is contained in:
alexpete
2021-03-05 11:26:34 -08:00
commit a10351f38d
27091 changed files with 5521199 additions and 0 deletions
@@ -0,0 +1,42 @@
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.
INTRODUCTION
------------
Remote Console is used to connect to a Lumberyard game instance. It can be used to send and
read console commands.
REQUIREMENTS
------------
* Python 3.7.x (64-bit)
It is recommended that you completely remove any other versions of Python
installed on your system.
INSTALL
-----------
It is recommended to set up these these tools with the lmbr_test tool Lumberyard's root directory:
lmbr_test pysetup install
To manually install the project in development mode:
python -m pip install -e .
UNINSTALLATION
--------------
To uninstall the project, use:
python -m pip uninstall remote-console
@@ -0,0 +1,13 @@
"""
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.
RemoteConsole: Used to interact with Lumberyard Launchers throught the Remote Console
to execute console commands.
"""
@@ -0,0 +1,283 @@
"""
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.
RemoteConsole: Used to interact with Lumberyard Launchers through the Remote Console to execute console commands.
"""
import socket
import threading
import logging
import os
logger = logging.getLogger(__name__)
# List of all console command types, some are not being used in this
BASE_MSG_TYPE = ord('0')
CONSOLE_MESSAGE_MAP = {
'NOOP': BASE_MSG_TYPE + 0,
'REQ': BASE_MSG_TYPE + 1,
'LOGMESSAGE': BASE_MSG_TYPE + 2,
'LOGWARNING': BASE_MSG_TYPE + 3,
'LOGERROR': BASE_MSG_TYPE + 4,
'COMMAND': BASE_MSG_TYPE + 5,
'AUTOCOMPLETELIST': BASE_MSG_TYPE + 6,
'AUTOCOMPLETELISTDONE': BASE_MSG_TYPE + 7,
'GAMEPLAYEVENT': BASE_MSG_TYPE + 22
}
def capture_screenshot_command(remote_console_instance):
# type: (RemoteConsole) -> None
"""
This is just a helper function to help capture in-game screenshot
:param remote_console_instance: RemoteConsole instance
:return: None
"""
screenshot_response = 'Screenshot: '
send_command_and_expect_response(remote_console_instance, 'r_GetScreenShot 2', screenshot_response)
logger.info("Screenshot has been taken.")
def send_command_and_expect_response(remote_console_instance, command_to_run, expected_log_line, timeout=60):
# type: (RemoteConsole, str, str, int) -> None
"""
This is just a helper function to help send a command and validate against expected output.
:param remote_console_instance: RemoteConsole instance
:param command_to_run: The command that you wish to run
:param expected_log_line: The console log line to expect in order to set the event to true
:param timeout: int representing the time to wait in seconds
:return: None, but will assert against the expected_response() boolean return for validation.
"""
event = threading.Event()
remote_console_instance.handlers[expected_log_line.encode()] = event
def expected_response():
return remote_console_instance.expect_log_line(expected_log_line, timeout)
remote_console_instance.send_command(command_to_run)
assert expected_response(), \
'{} command failed. Was looking for {} in the log but did not find it.'.format(
command_to_run, expected_log_line)
def _default_on_message_received(raw):
# type: (str) -> None
"""
This will just print the raw data from the message received. We are striping white spaces before and after the
message and logging it. We are passing this function to the remote console instance as a default. On any received
message a user can overwrite the functionality with any function that they would like.
:param raw: Raw string returned from the remote console
:return: None
"""
refined = raw.strip()
if len(refined):
logger.info(raw)
def _default_disconnect():
# type: () -> None
"""
On a disconnect a user can overwrite the functionality with any function, this one will just print to the
logger a line 'Disconnecting from the Port.'
:return: None
"""
logger.info('Disconnecting from the Port')
class RemoteConsole:
def __init__(self, addr='127.0.0.1', port=4600, on_disconnect=_default_disconnect,
on_message_received=_default_on_message_received):
# type: (str, int, func, func) -> None
"""
Creates a port connection using port 4600 to issue console commands and poll for specific console log lines.
:param addr: The ip address where the launcher lives that we want to connect to
:param port: The port where the remote console will be connecting to. This usually starts at 4600, and is
increased by one for each additional launcher that is opened
:param on_disconnect: User can supply their own disconnect functionality if they would like
:param on_message_received: on_message_received function in case they want their logging info handled in a
different way
"""
self.handlers = {}
self.connected = False
self.addr = addr
self.port = port
self.on_disconnect = on_disconnect
self.on_display = on_message_received
self.pump_thread = threading.Thread(target=self.pump)
self.stop_pump = threading.Event()
self.ready = threading.Event()
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
def start(self, timeout=10):
# type: (int) -> None
"""
Starts the socket connection to the Launcher instance.
:param timeout: The timeout in seconds for the pump thread to get ready before raising an exception.
"""
if self.connected:
logger.warning('RemoteConsole is already connected.')
return
# Do not wait more than 3.0 seconds per connection attempt.
self.socket.settimeout(3.0)
num_ports_to_scan = 8
max_port = self.port + num_ports_to_scan
while self.port < max_port:
try:
self.socket.connect((self.addr, self.port))
logger.info('Successfully connected to port: {}'.format(self.port))
break
except:
self.port += 1
if self.port >= max_port:
from_port_to_port = "from port {} to port {}".format(
self.port-num_ports_to_scan, self.port-1)
raise Exception(
"Remote console connection never became ready after scanning {}".format(
from_port_to_port))
# Clear the timeout. Further socket operations won't timeout.
self.socket.settimeout(None)
self.pump_thread.start()
if not self.ready.wait(timeout):
raise Exception("Remote console connection never became ready")
self.connected = True
logger.info('Remote Console Started at port {}'.format(self.port))
def stop(self):
# type: () -> None
"""
Stops and closes the socket connection to the Launcher instance.
"""
if not self.connected:
logger.warning('RemoteConsole is not connected, cannot stop.')
return
self.stop_pump.set()
self.socket.shutdown(socket.SHUT_WR)
self.socket.close()
self.pump_thread.join()
self.connected = False
def send_command(self, command):
# type: (str) -> None
"""
Transforms and sends commands to the Launcher instance.
:param command: The command to be sent to the Launcher instance
"""
message = self._create_message(CONSOLE_MESSAGE_MAP['COMMAND'], command)
try:
self._send_message(message)
except:
self.on_disconnect()
def pump(self):
# type: () -> None
"""
Pump function that is used by the pump_thread. Listens to receive messages from the socket
and disconnects during an exception.
"""
while not self.stop_pump.is_set():
# Sending a NOOP message in order to get log lines
self._send_message(self._create_message(CONSOLE_MESSAGE_MAP['NOOP']))
try:
self._handle_message(self.socket.recv(4096))
except:
self.on_disconnect()
self.stop_pump.set()
def expect_log_line(self, match_string, timeout=30):
# type: (str, int) -> bool
"""
Looks for a log line event to expect within a time frame. Returns False is timeout is reached.
:param match_string: The string to match that acts as a key
:param timeout: The timeout to wait for the log line in seconds
:return: boolean True if match_string found, False otherwise.
"""
logger.info("waiting for event '{}' for '{}' seconds".format(match_string, timeout))
event = threading.Event()
self.handlers[match_string.encode()] = event
event_success = event.wait(timeout)
logger.warning(
'Returning "{}" for expect_log_line() - previously this returned a function object, '
'so if you see failures now this may be why.'.format(event_success))
return event_success
def _create_message(self, message_type, message_body=''):
# type: (bytes, str) -> bytearray
"""
Transforms a message to be sent to the launcher. The string is converted to a bytearray and
is prepended with the message type and appended with an ending 0.
:param message_type: Uses CONSOLE_MESSAGE_MAP to prepend the bytearray message
:param message_body: The message string to be converted
"""
message_body = message_body.encode()
message = bytearray(0)
message.append(message_type)
for message_body_char in message_body:
message.append(message_body_char)
message.append(0)
return message
def _send_message(self, message):
# type: (bytearray) -> None
"""
Sends console commands through the socket connection to the launcher. The message string should
first be transformed into a bytearray.
:param message: The message to be sent to the Launcher instance
"""
self.socket.sendall(message)
def _handle_message(self, message):
# type: (bytearray) -> None
"""
Handles the messages and and will poll for expected console messages that we are looking for and set() events to True.
Displays the message if we determine it is a logging message.
:param message: The message (a byte array) received to be handled in various ways
"""
# message[0] is the representation of the message type inside of the message received from our launchers
message_type = message[0]
# ignoring the first byte and the last byte. The first byte is the message type and the last byte is a
# Null terminator
message_body = message[1:-1]
# display the message if it's a logging message type
if CONSOLE_MESSAGE_MAP['LOGMESSAGE'] <= message_type <= CONSOLE_MESSAGE_MAP['LOGERROR']:
self.on_display(message_body)
for key in self.handlers.keys():
# message received, set flag handler as True for success
if key in message_body:
logger.info("matched key=<{}>".format(key))
self.handlers[key].set()
continue
# The very first connection using the socket will return all of the auto complete items, turned off so no one
# wouldn't need to see them
elif message_type == CONSOLE_MESSAGE_MAP['AUTOCOMPLETELIST']:
pass
# The after the autocompletelists finishes we will be ready to send console commands we determine that by
# looking at for an autocompletelistdone message
elif message_type == CONSOLE_MESSAGE_MAP['AUTOCOMPLETELISTDONE']:
self.ready.set()
# cleanup expect_log_line handers if the matching string was found or timeout happened.
handlers_dict = self.handlers.copy()
for key in handlers_dict.keys():
if self.handlers[key].is_set():
del self.handlers[key]
@@ -0,0 +1,39 @@
"""
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.
"""
import os
import platform
from setuptools import setup, find_packages
from setuptools.command.develop import develop
from setuptools.command.build_py import build_py
PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__))
PYTHON_64 = platform.architecture()[0] == '64bit'
if __name__ == '__main__':
if not PYTHON_64:
raise RuntimeError("32-bit Python is not a supported platform.")
with open(os.path.join(PROJECT_ROOT, 'README.txt')) as f:
long_description = f.read()
setup(
name="ly_remote_console",
version="1.0.0",
description='Python interface to the Lumberyard Remote Console',
long_description=long_description,
packages=find_packages(exclude=['tests']),
tests_require=['mock'],
entry_points={},
)
@@ -0,0 +1,34 @@
#
# 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.
#
# Ly Remote Console tests.
#
# Unit tests.
ly_add_pytest(
NAME RemoteConsole_UnitTests_main_no_gpu
PATH ${CMAKE_CURRENT_LIST_DIR}/unit/
)
if(PAL_TRAIT_BUILD_HOST_TOOLS AND PAL_TRAIT_BUILD_TESTS_SUPPORTED AND AutomatedTesting IN_LIST LY_PROJECTS)
# Integration tests.
# ly_add_pytest(
# NAME RemoteConsole_IntegTests_periodic_no_gpu
# PATH ${CMAKE_CURRENT_LIST_DIR}/integ/test_remote_console.py
# TEST_SERIAL
# TEST_SUITE periodic
# RUNTIME_DEPENDENCIES
# Legacy::Editor
# AssetProcessor
# AutomatedTesting.GameLauncher
# AutomatedTesting.Assets
# Legacy::CryRenderNULL
# )
endif()
@@ -0,0 +1,61 @@
"""
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.
"""
import filecmp
import os
import pytest
# ly_test_tools dependencies.
import ly_remote_console.remote_console_commands as remote_console_commands
from ly_test_tools import WINDOWS
@pytest.fixture
def remote_console(request):
"""
Creates a RemoteConsole() class instance to send console commands to the
Lumberyard client console.
:param request: _pytest.fixtures.SubRequest class that handles getting
a pytest fixture from a pytest function/fixture.
:return: ly_remote_console.remote_console_commands.RemoteConsole class instance
representing the Lumberyard remote console executable.
"""
# Initialize the RemoteConsole object to send commands to the Lumberyard client console.
console = remote_console_commands.RemoteConsole()
# Custom teardown method for this remote_console fixture.
def teardown():
console.stop()
# Utilize request.addfinalizer() to add custom teardown() methods.
request.addfinalizer(teardown) # This pattern must be used in pytest version
return console
@pytest.mark.skipif(not WINDOWS, reason="Editor currently only functions on Windows")
@pytest.mark.parametrize("launcher_platform", ['windows_editor'])
class TestRemoteConsole(object):
@pytest.mark.parametrize("project", ["AutomatedTesting"])
@pytest.mark.parametrize("level", ['Simple'])
@pytest.mark.parametrize("load_wait", [120])
def test_RemoteConsole_TakeScreenshot_Success(self, launcher, launcher_platform, remote_console, level, load_wait):
with launcher.start():
remote_console.start()
launcher_load = remote_console.expect_log_line(
match_string='========================== '
'Finished loading textures '
'============================',
timeout=load_wait)
remote_console_commands.capture_screenshot_command(remote_console)
assert True
@@ -0,0 +1,2 @@
[pytest]
python_files = 'test_*.py' , '*_test.py' , '*_tests.py'
@@ -0,0 +1,158 @@
"""
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.
test_ly_remote_console contains all the unit test for remote console tool.
"""
try: # Py2
import mock
except ImportError: # Py3
import unittest.mock as mock
import pytest
import ly_remote_console.remote_console_commands as remote_console
@pytest.mark.unit
class TestScreenShot():
@mock.patch('ly_remote_console.remote_console_commands.RemoteConsole')
@mock.patch('ly_remote_console.remote_console_commands.capture_screenshot_command')
def test_CaptureScreenshot_MockSendRequest_SendRequestCalled(self, mock_send_screenshot, mock_remote_console):
remote_console.capture_screenshot_command(mock_remote_console)
assert len(mock_send_screenshot.mock_calls) == 1
def test_SendScreenshotCommand_MockConsole_LineReadSuccess(self):
mock_remote_console = mock.MagicMock()
mock_remote_console.expect_log_line.return_value = True
try:
remote_console.send_command_and_expect_response(mock_remote_console, 'foo_command', 'foo_line')
except AssertionError:
assert False
def test_SendScreenshotCommand_MockConsole_LineReadFailure(self):
mock_remote_console = mock.MagicMock()
mock_remote_console.expect_log_line.return_value = False
with pytest.raises(AssertionError):
remote_console.send_command_and_expect_response(mock_remote_console, 'foo_command', 'foo_line')
@pytest.mark.unit
class TestRemoteConsole():
@mock.patch('socket.socket', mock.MagicMock())
@mock.patch('ly_remote_console.remote_console_commands.threading', mock.MagicMock())
def test_Start_CheckDefaultConnection_ConnectionTrue(self):
rc_instance = remote_console.RemoteConsole()
rc_instance.start()
assert rc_instance.connected
@mock.patch('socket.socket')
@mock.patch('ly_remote_console.remote_console_commands.threading', mock.MagicMock())
def test_Start_SocketConnection_SocketCalledOnce(self, mock_socket):
rc_instance = remote_console.RemoteConsole()
rc_instance.start()
mock_socket.assert_called_once()
@mock.patch('socket.socket')
@mock.patch('ly_remote_console.remote_console_commands.threading', mock.MagicMock())
def test_Stop_SocketCommand_SocketCalledForShutdownAndClose(self, mock_socket):
rc_instance = remote_console.RemoteConsole()
rc_instance.stop()
mock_socket.assert_called_once()
@mock.patch('socket.socket', mock.MagicMock)
@mock.patch('ly_remote_console.remote_console_commands.threading', mock.MagicMock())
def test_ExpectLogLine_CheckIfEventLogged_DoesNotReturnFalse(self):
rc_instance = remote_console.RemoteConsole()
result = rc_instance.expect_log_line("test")
assert result
@mock.patch('socket.socket', mock.MagicMock())
@mock.patch('ly_remote_console.remote_console_commands.RemoteConsole._create_message')
def test_SendCommand_CheckMessageCreated_AssertCreateMessageCalled(self, mock_create_message):
rc_instance = remote_console.RemoteConsole()
rc_instance.send_command("testCommand")
mock_create_message.assert_called_once()
@mock.patch('socket.socket', mock.MagicMock())
def test_Pump_CheckSendMessage_AssertSendMessageCalled(self):
rc_instance = remote_console.RemoteConsole()
rc_instance._send_message = mock.MagicMock()
rc_instance._handle_message = mock.MagicMock()
rc_instance._handle_message.side_effect = Exception() # to force except path in pump()
rc_instance.pump()
rc_instance._send_message.assert_called_once()
@mock.patch('socket.socket', mock.MagicMock())
@mock.patch('ly_remote_console.remote_console_commands.threading', mock.MagicMock())
def test_CreateMessage_SimpleNoopMessage_MessageCreated(self):
rc_instance = remote_console.RemoteConsole()
expected = bytearray(b'0foo\x00')
actual = rc_instance._create_message(remote_console.CONSOLE_MESSAGE_MAP['NOOP'], 'foo')
assert expected == actual
@mock.patch('socket.socket')
@mock.patch('ly_remote_console.remote_console_commands.threading', mock.MagicMock())
def test_SendMessage_SimpleMessage_SocketCalled(self, mock_socket):
rc_instance = remote_console.RemoteConsole()
msg = bytearray(0)
rc_instance._send_message(msg)
assert mock_socket.sendall.called_once_with(msg)
@mock.patch('socket.socket', mock.MagicMock())
@mock.patch('ly_remote_console.remote_console_commands.threading', mock.MagicMock())
def test_HandleMessage_LogMessage_HandlerSet(self):
rc_instance = remote_console.RemoteConsole()
rc_instance.on_display = mock.MagicMock()
rc_instance.ready = mock.MagicMock()
mock_evt_handler = mock.MagicMock()
msg = b'2foo warning0' # in python3 socket.recv returns byte array. 2 is LOGMESSAGED
rc_instance.handlers[b'foo warning'] = mock_evt_handler
rc_instance._handle_message(msg)
rc_instance.on_display.assert_called_once_with(b'foo warning')
mock_evt_handler.set.assert_called_once()
assert 'foo warning' not in rc_instance.handlers.keys()
rc_instance.ready.set.assert_not_called()
@mock.patch('socket.socket', mock.MagicMock())
@mock.patch('ly_remote_console.remote_console_commands.threading', mock.MagicMock())
def test_HandleMessage_AutoCompleteList_NoOp(self):
rc_instance = remote_console.RemoteConsole()
rc_instance.on_display = mock.MagicMock()
rc_instance.ready = mock.MagicMock()
msg = b'60' # in python3 socket.recv returns byte array. 6 is AUTOCOMPLETELIST
rc_instance._handle_message(msg)
rc_instance.on_display.assert_not_called()
rc_instance.ready.set.assert_not_called()
@mock.patch('socket.socket', mock.MagicMock())
@mock.patch('ly_remote_console.remote_console_commands.threading', mock.MagicMock())
def test_HandleMessage_AutoCompleteListDone_ReadySet(self):
rc_instance = remote_console.RemoteConsole()
rc_instance.on_display = mock.MagicMock()
rc_instance.ready = mock.MagicMock()
msg = b'70' # in python3 socket.recv returns byte array. 7 is AUTOCOMPLETELISTDONE
rc_instance._handle_message(msg)
rc_instance.on_display.assert_not_called()
rc_instance.ready.set.assert_called_once()