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,15 @@
"""
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.
"""
# Stores LyTT variables configured via pytest CLI plugin, for later access without dependency on fixtures
# these values should only be modified during initialization, or patched during unit tests
build_directory = None
output_path = None
@@ -0,0 +1,152 @@
"""
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.
Adds a hook for the @pytest.mark.test_case_id decorator which allows users to mark
tests with test case IDs that show up on pytest .xml reports.
Additionally, users can use the "--test-case-ids" CLI argument to only run the
comma-separated test case IDs listed after the arg.
"""
import logging
import pytest
import six
ID_MARKER = "test_case_id"
log = logging.getLogger(__name__)
class TestCaseIDException(Exception):
"""Raised when "--test-case-ids" CLI arg is invalid."""
pass
def pytest_configure(config):
"""
Pytest configuration for @pytest.mark.test_case_id markers.
"""
config.addinivalue_line(
'markers',
'test_case_id: Add test case ID to test.',
)
def pytest_addoption(parser):
"""
Will cause only the comma-separated list of test case IDs
listed after '--test-case-ids' to be run.
:param parser: pytest-provided fixture for argparse.ArgumentParser
"""
parser.addoption("-I", "--test-case-ids", nargs='?',
help="Only run tests marked with the specified ID(s).")
def _pytest_runtest_makereport_imp(report, item):
try:
test_case_id_marker = item.get_marker(ID_MARKER)
except AttributeError: # use get_closest_marker() instead
test_case_id_marker = item.get_closest_marker(ID_MARKER)
if report.when == 'call' and test_case_id_marker is not None:
test_case_ids = _parse_test_case_ids(test_case_id_marker.args)
try:
xml_report = getattr(item.config, '_xml')
except AttributeError:
log.warning('No .xml report found in test, skipping pytest reporting hooks.')
return
for test_case_id in test_case_ids:
report_node = xml_report.node_reporter(report.nodeid)
report_node.add_property('test_case_id', test_case_id)
@pytest.hookimpl(hookwrapper=True, trylast=True)
def pytest_runtest_makereport(item, call):
"""
Modify the pytest jUnitXML report to add test case IDs.
Tests require the @pytest.mark.test_case_id decorator
in order to have the test case ID appear on the report.
:param item: Test in the current pytest runner session.
:param call: Call in the current pytest runner session.
"""
outcome = yield
report = outcome.get_result()
_pytest_runtest_makereport_imp(report, item)
def pytest_collection_modifyitems(items, config):
"""
Reduces test collection to tests with the 'test_case_id'
specified by the user using the --test-case-ids CLI arg.
:param items: All tests within the pytest runner session.
:param config: Call config for pytest runner session.
"""
id_filtering_enabled = config.option.test_case_ids
selected_items = set()
deselected_items = set()
if id_filtering_enabled:
filtered_test_case_ids = _parse_test_case_ids(
id_filtering_enabled.split(','))
for item in items:
try:
test_case_id_marker = item.get_marker(ID_MARKER)
except AttributeError:
log.debug('item.get_marker() call failed, using item.get_closest_marker() instead.')
test_case_id_marker = item.get_closest_marker(ID_MARKER)
test_case_ids = _parse_test_case_ids(test_case_id_marker.args)
if _any_exist_in(test_case_ids, filtered_test_case_ids):
selected_items.add(item)
else:
deselected_items.add(item)
config.hook.pytest_deselected(items=deselected_items)
items[:] = selected_items
def _any_exist_in(select_from, find_in):
"""
:param select_from: iterable keys to find
:param find_in: iterable to search in
:return: True if any item in the first iterable exists in the second
"""
for target in select_from:
if target in find_in:
return True
return False
def _parse_test_case_ids(test_case_ids):
"""
Return a set representing unique test case ID values
:param test_case_ids: list or tuple containing test case ID values (strings or ints)
:return: set of unique values
"""
parsed_ids = set()
for test_case_id in test_case_ids:
if type(test_case_id) == int:
parsed_ids.add(test_case_id)
else:
try: # dedupe strings which are identical to integers
target_id = int(test_case_id)
except ValueError as err:
log.debug(err)
if type(test_case_id) == str and len(test_case_id.strip()) > 0:
target_id = test_case_id # valid string
else:
problem = TestCaseIDException('Invalid test_case_id detected: "{}", test_case_id must be of type '
'str or int.'.format(test_case_id))
six.raise_from(problem, err)
parsed_ids.add(target_id)
return parsed_ids
@@ -0,0 +1,83 @@
"""
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 sys
from ly_test_tools import WINDOWS
def _get_test_launcher_cmd():
"""
Helper function to determine the test launcher command for the current platform
:return: The test launcher command
"""
python_runner = "python3.cmd"
if not WINDOWS:
python_runner = "python3.sh"
current_dir = sys.executable
# Look upward a handful of levels to check for the LY python entry point script
# Assumes folder structure similar to: /Python/python.cmd
for _ in range(10):
python_wrapper = os.path.join(current_dir, python_runner)
if os.path.exists(python_wrapper):
return f"{python_wrapper} -m pytest "
# Using an explicit else to avoid aberrant behavior from following filesystem links
else:
current_dir = os.path.abspath(os.path.join(current_dir, os.path.pardir))
return f"{sys.executable} -m pytest "
def _format_cmd(launcher_cmd, test_path, nodeid):
"""
Builds a command with required arguments to run a test
:param launcher_cmd: The test launcher command
:param test_path: File or directory that contains the test(s) that were run
:param nodeid: A test node id, with parametrized values
:return: Formatted command to re-run a test with parametrized values
"""
# Assign the test id argument accordingly:
# the node id (with parameters) is already part of the test path argument
# when a single test case was invoked or
# the node id (with parameters) includes the test filename when a whole
# test module was invoked else
# append the nodeId to the path when a whole test directory was invoked
if nodeid == os.path.split(test_path)[-1]:
test_id_argument = test_path
elif os.path.split(test_path)[-1] in nodeid:
test_id_argument = os.path.abspath(
os.path.join(os.path.dirname(test_path), nodeid))
else:
test_id_argument = os.path.abspath(
os.path.join(test_path, os.path.normpath(nodeid)))
return f"{launcher_cmd}{test_id_argument}"
def build_rerun_commands(test_path, nodeids):
"""
Builds a list of commands to run tests
:param test_path: File or directory that contains the test(s) that were run
:param nodeids: List of test node ids, with parametrized values
:return: A list of commands to re-run tests
"""
commands = []
test_launcher_cmd = _get_test_launcher_cmd()
for nodeid in nodeids:
commands.append(_format_cmd(test_launcher_cmd, test_path, nodeid))
return commands
@@ -0,0 +1,74 @@
"""
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 ly_test_tools._internal.pytest_plugin.failed_test_rerun_command as rerun
def _add_commands(terminalreporter, header, test_path, nodeids):
"""
Add test re-run commands to the TerminalReporter object
:param terminalreporter: Pytest's TerminalReporter object that contains test result information.
:param header: Message to write to TerminalReporter before list is added
:param test_path: File or directory that contains the test(s) that to run
:param nodeids: List of test node ids, with parametrized values
"""
terminalreporter.write_line(header)
if nodeids:
commands = rerun.build_rerun_commands(test_path, nodeids)
for command in commands:
terminalreporter.write_line(command)
else:
terminalreporter.write_line("Error, Test node id list is empty!")
def pytest_terminal_summary(terminalreporter, exitstatus):
"""
Pytest's hook for terminal reporting. This hook is invoked at the end of the test session.
:param terminalreporter: Pytest's TerminalReporter object that contains test result information.
:param exitstatus: Exit that will be returned to the system
"""
# Add to the TerminalReport a section for failed test re-running
failures = terminalreporter.stats.get('failed', [])
errors = terminalreporter.stats.get('error', [])
failure_count = len(failures)
error_count = len(errors)
if failure_count or error_count:
file_or_dir_option = terminalreporter.config.getoption('file_or_dir', default=[])
if file_or_dir_option:
test_path = file_or_dir_option[0]
else:
test_path = ''
terminalreporter.section("Test failure and error troubleshooting")
if failure_count:
nodeids = [os.path.basename(report.nodeid) for report in failures]
_add_commands(
terminalreporter,
"Use the following commands to re-run each test that failed locally\n"
"(NOTE: The 'PYTHON' or 'PYTHONPATH' environment variables need values for accurate commands): ",
test_path, nodeids)
if error_count:
nodeids = [os.path.basename(report.nodeid) for report in errors]
_add_commands(
terminalreporter,
"Use the following commands to re-run each test that had errors locally\n"
"(NOTE: The 'PYTHON' or 'PYTHONPATH' environment variables need values for accurate commands): ",
test_path, nodeids)
@@ -0,0 +1,438 @@
"""
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.
Generic fixtures bundled with LyTestTools
These fixtures will be available to the end user without requiring to import any file.
"""
import getpass
import logging
import os
import socket
import time
from datetime import datetime
import pytest
import ly_test_tools._internal.pytest_plugin
import ly_test_tools._internal.log.py_logging_util as py_logging_util
import ly_test_tools._internal.managers.ly_process_killer as ly_process_killer
import ly_test_tools.builtin.helpers as helpers
import ly_test_tools.environment.file_system
import ly_test_tools.launchers.launcher_helper
import ly_test_tools.launchers.platforms.base
import ly_test_tools.environment.watchdog
from ly_test_tools import ALL_PLATFORM_OPTIONS, HOST_OS_PLATFORM
logger = logging.getLogger(__name__)
TIMESTAMP_FORMAT = '%Y-%m-%dT%H-%M-%S-%f' # ISO with colon and period replaced to dash
TOOLS_INFO_LOG_NAME = "ToolsInfo.log"
TOOLS_DEBUG_LOG_NAME = "ToolsDebug.log"
def pytest_addoption(parser):
"""
Adds CLI options to launch with pytest.
Pytest will find and visit this function during its initialization
:param parser: pytest-provided fixture for argparse.ArgumentParser
"""
parser.addoption("--output-path",
help="A folder for test artifacts and logs")
parser.addoption("--build-directory", nargs='?', default='',
help="An existing CMake binary output directory which contains the lumberyard executables,"
"such as: D:/ly/dev/windows_vs2017/bin/profile/")
def pytest_configure(config):
"""
Save custom CLI options during Pytest configuration, so they are later accessible without using fixtures
Pytest will find and visit this function after initialization
:param config: pytest-provided fixture for configured arguments
"""
ly_test_tools._internal.pytest_plugin.build_directory = _get_build_directory(config)
ly_test_tools._internal.pytest_plugin.output_path = _get_output_path(config)
def _get_build_directory(config):
"""
Fetch and verify the cmake build directory CLI arg, without creating an error when unset
:param config: pytest config object
"""
custom_build_directory = config.getoption('--build-directory', '')
if custom_build_directory:
logger.debug(f'Custom build directory set via cli arg to: {custom_build_directory}')
if not os.path.exists(custom_build_directory):
raise ValueError(f'Pytest argument "--build-directory" does not exist at: {custom_build_directory}')
else:
# only warn when unset, allowing non-LyTT tests to still use pytest
logger.warning(f'Pytest argument "--build-directory" was not provided, tests using LyTestTools will fail')
return custom_build_directory
def _get_output_path(config):
"""
Fetch and verify the CLI arg for the path where tests artifacts are saved, without creating an error when unset
:param config: pytest config object
"""
custom_output_path = config.getoption("--output-path")
if custom_output_path:
logger.debug(f'Custom output_path set to: {str(custom_output_path)}')
output_path = custom_output_path
else:
# from pytest_runner
output_path = os.path.join(os.getcwd(),
"TestResults",
datetime.now().strftime(TIMESTAMP_FORMAT),
"pytest_results")
logger.debug(f'Defaulting output_path to: {str(output_path)}')
os.makedirs(output_path, exist_ok=True)
return output_path
@pytest.fixture(scope="session")
def record_suite_property(request):
"""
Sets a global property at the Suite level in pytest's internal report, adding it if it does not yet exist else
overwriting all current values.
These properties become part of the test report and are available to the configured reporters, e.g. JUnit XML.
The fixture is callable with ``(name, value)``, with value being automatically xml-encoded.
Example::
def test_function(record_suite_property):
record_suite_property("example_key", 1)
"""
return _record_suite_property(request)
def _record_suite_property(request):
"""Separate implementation to call directly during unit tests"""
xml = getattr(request.config, "_xml", None)
if xml is not None:
def set_prop(name, value):
property_exists = [propname for propname in xml.global_properties if propname[0] == name]
if property_exists:
for prop in range(len(xml.global_properties)):
if xml.global_properties[prop][0] == name:
xml.global_properties[prop] = name, value
else:
xml.add_global_property(name, value)
return set_prop
else:
def set_prop_noop(name, value):
logger.debug(
f"Pytest junitxml unexpectedly not configured, unable to add global suite property '{name}' "
f"with value '{value}'")
return set_prop_noop
@pytest.fixture(scope="session")
def output_path(request):
"""
Returns the desired name for the folder that will store the results of the tests. If no name is provided, it will
default to naming the folder after the timestamp at the moment this test session started. Datetime as string in the
format YYYY-MM-DDTHH_MM_SS_F This fixture is useful to have an unique ID for the current session.
:return: custom logs path, defaulting to current timestamp
"""
return ly_test_tools._internal.pytest_plugin.output_path
@pytest.fixture
def build_directory(request):
return ly_test_tools._internal.pytest_plugin.build_directory
@pytest.fixture
def record_build_name(record_suite_property):
"""
Updates build name in the pytest XML results
:return: function which accepts parameter build_name
"""
return _record_build_name(record_suite_property)
def _record_build_name(record_suite_property):
"""Separate implementation to call directly during unit tests"""
return lambda build_name: record_suite_property("build", build_name)
@pytest.fixture(autouse=True)
def record_test_timestamp(record_property):
"""
Adds a timestamp to the current test in the XML report
"""
return _record_test_timestamp(record_property)
def _record_test_timestamp(record_property):
"""Separate implementation to call directly during unit tests"""
record_property("timestamp", datetime.now().strftime(TIMESTAMP_FORMAT))
@pytest.fixture(scope="session", autouse=True)
def record_suite_data(record_suite_property):
"""
Sets default suite information for pytest's XML output
"""
return _record_suite_data(record_suite_property)
def _record_suite_data(record_suite_property):
"""Separate implementation to call directly during unit tests"""
record_suite_property("timestamp", str(datetime.now().strftime(TIMESTAMP_FORMAT)))
record_suite_property("hostname", str(socket.gethostname()))
record_suite_property("username", str(getpass.getuser()))
@pytest.fixture(scope="function")
def editor(request, workspace, crash_log_watchdog):
# type: (...) -> ly_test_tools.launchers.platforms.base.Launcher
return _editor(
request=request,
workspace=workspace,
launcher_platform=get_fixture_argument(request, 'launcher_platform', HOST_OS_PLATFORM))
def _editor(request, workspace, launcher_platform):
"""Separate implementation to call directly during unit tests"""
editor = ly_test_tools.launchers.launcher_helper.create_editor(workspace, launcher_platform)
def teardown():
editor.stop()
request.addfinalizer(teardown)
return editor
def get_fixture_argument(request, argument_name, default_value):
if argument_name in request.fixturenames:
return request.getfixturevalue(argument_name)
return default_value
@pytest.fixture(scope="function")
def launcher(request, workspace, crash_log_watchdog):
# type: (...) -> ly_test_tools.launchers.platforms.base.Launcher
return _launcher(
request=request,
workspace=workspace,
launcher_platform=get_fixture_argument(request, 'launcher_platform', HOST_OS_PLATFORM),
level=get_fixture_argument(request, 'level', ''))
def _launcher(request, workspace, launcher_platform, level=""):
"""Separate implementation to call directly during unit tests"""
if not level:
launcher = ly_test_tools.launchers.launcher_helper.create_launcher(
workspace, launcher_platform)
else:
launcher = ly_test_tools.launchers.launcher_helper.create_launcher(
workspace, launcher_platform, ['+map', level])
def teardown():
launcher.stop()
request.addfinalizer(teardown)
return launcher
@pytest.fixture(scope="function")
def dedicated_launcher(request, workspace, crash_log_watchdog):
# type: (...) -> ly_test_tools.launchers.platforms.base.Launcher
return _dedicated_launcher(
request=request,
workspace=workspace,
launcher_platform=get_fixture_argument(request, 'launcher_platform', HOST_OS_PLATFORM),
level=get_fixture_argument(request, 'level', ''))
def _dedicated_launcher(request, workspace, launcher_platform, level=""):
"""Separate implementation to call directly during unit tests"""
if not level:
launcher = ly_test_tools.launchers.launcher_helper.create_dedicated_launcher(
workspace, launcher_platform)
else:
launcher = ly_test_tools.launchers.launcher_helper.create_dedicated_launcher(
workspace, launcher_platform, ['+map', level])
def teardown():
launcher.stop()
request.addfinalizer(teardown)
return launcher
@pytest.fixture
def automatic_process_killer(request):
# type: (_pytest.fixtures.SubRequest) -> ly_process_killer
"""
Automatically kills existing Lumberyard processes before a test.
Relies on parametrizing "processes_to_kill" with a list of process names to kill.
If no "processes_to_kill" is found, it will default to ly_process_killer.LY_PROCESS_KILL_LIST instead.
:param request: _pytest.fixtures.SubRequest request object.
:return: ly_process_killer module.
"""
processes_to_kill = get_fixture_argument(request, 'processes_to_kill', ly_process_killer.LY_PROCESS_KILL_LIST)
return _automatic_process_killer(processes_to_kill)
def _automatic_process_killer(processes_to_kill):
# type: (list) -> ly_process_killer
"""
Separate implementation to call directly during unit tests.
"""
# Detect processes.
processes_detected = ly_process_killer.detect_lumberyard_processes(processes_list=processes_to_kill)
# Kill processes.
ly_process_killer.kill_processes(processes_list=processes_detected)
return ly_process_killer
@pytest.fixture(scope="function")
def workspace(request, # type: _pytest.fixtures.SubRequest
build_directory, # type: str
project, # type: str
record_property, # type: _pytest.junitxml.record_property
record_build_name, # type: ly_test_tools._internal.pytest_plugin.test_tools_fixtures.record_build_name
output_path, # type: ly_test_tools._internal.pytest_plugin.test_tools_fixtures.output_path
asset_processor_platform, # type: str
):
# type: (...) -> ly_test_tools._internal.managers.workspace.WorkspaceManager
"""
Create a new platform-specific workspace manager for the current lumberyard build, and configure log reporting.
Expects that Lumberyard has already been built for the target platform, configuration, project, and spec
:param request: _pytest.fixtures.SubRequest request object
:param build_directory: path to the build directory
:param project: Project name to use
:param record_property: PyTest record_property fixture
:param record_build_name: LyTestTools record_build_name fixture
:param output_path: LyTestTools output_path fixture
:param asset_processor_platform: name of the platform to target for the AssetProcessor
:return: A fully configured workspace manager
"""
return _workspace(
request, build_directory, project, record_property, record_build_name, output_path, asset_processor_platform)
def _workspace(request, # type: _pytest.fixtures.SubRequest
build_directory, # type: str
project, # type: str
record_property, # type: _pytest.junitxml.record_property
record_build_name, # type: ly_test_tools._internal.pytest_plugin.test_tools_fixtures.record_build_name
output_path, # type: ly_test_tools._internal.pytest_plugin.test_tools_fixtures.output_path
asset_processor_platform, # type: str
):
"""Separate implementation to call directly during unit tests"""
workspace = helpers.create_builtin_workspace(
build_directory=build_directory,
project=project,
output_path=output_path,
)
# Build names for test artifact folders:
test_module = request.node.module.__name__.split('.')[-1]
test_class = request.node.getmodpath().split('.')[0]
test_method = request.node.originalname
test_name = workspace.artifact_manager.generate_folder_name(
test_module, test_class, test_method)
def teardown():
log_name = f"{test_name}-logs"
log_path = os.path.join(workspace.artifact_manager.artifact_path, log_name)
record_build_name(HOST_OS_PLATFORM)
path = os.path.basename(workspace.artifact_manager.gather_artifacts(log_path))
record_property("log", path)
py_logging_util.terminate_logging()
workspace.artifact_manager.set_test_name() # Reset log name for this test
helpers.teardown_builtin_workspace(workspace)
request.addfinalizer(teardown)
artifact_folder_count = request.session.testscollected # Amount of folders to create for test_name.
helpers.setup_builtin_workspace(workspace, test_name, artifact_folder_count)
# Must be called after helpers.setup_builtin_workspace() above:
info_log_path = workspace.artifact_manager.generate_artifact_file_name(TOOLS_INFO_LOG_NAME)
debug_log_path = workspace.artifact_manager.generate_artifact_file_name(TOOLS_DEBUG_LOG_NAME)
py_logging_util.initialize_logging(info_log_path, debug_log_path)
# Bind the newly created log files to the workspace.
workspace.info_log_path = info_log_path
workspace.debug_log_path = debug_log_path
workspace.asset_processor_platform = asset_processor_platform
return workspace
@pytest.fixture(scope="function")
def crash_log_watchdog(request, workspace):
# type: (...) -> ly_test_tools.environment.watchdog.CrashLogWatchdog
return _crash_log_watchdog(request, workspace, get_fixture_argument(request, 'raise_on_crash', True))
def _crash_log_watchdog(request, workspace, raise_on_crash):
"""Separate implementation to call directly during unit tests"""
error_log = os.path.join(workspace.paths.project_log(), 'error.log')
crash_log_watchdog = ly_test_tools.environment.watchdog.CrashLogWatchdog(
error_log, raise_on_condition=raise_on_crash)
def teardown():
# stop() will either raise an exception or log an error if watchdog has found an error log
crash_log_watchdog.stop()
request.addfinalizer(teardown)
crash_log_watchdog.start()
return crash_log_watchdog
@pytest.fixture(scope="function",
params=[HOST_OS_PLATFORM])
def asset_processor_platform(request):
"""
Platform to target for the AssetProcessor for modifying AssetProcessorPlatformConfig.ini
:param request: _pytest.fixtures.SubRequest request object.
:return: string representing the AssetProcessor platform to use.
"""
return _asset_processor_platform(request)
def _asset_processor_platform(request):
"""Separate implementation to call directly during unit tests"""
ap_platform = request.param
if ap_platform in ALL_PLATFORM_OPTIONS:
logger.debug(f'Returning asset_processor_platform: "{ap_platform}".')
return ap_platform
else:
raise ValueError(
f'asset_processor_platform: "{ap_platform}" is not valid. '
f'Please select from one of the following: {ALL_PLATFORM_OPTIONS}')