Merge "Add go/a-update link/banner to adevice" into main
diff --git a/Android.bp b/Android.bp
index 60c8327..67f7510 100644
--- a/Android.bp
+++ b/Android.bp
@@ -57,3 +57,11 @@
canonical_path_from_root: false,
},
}
+
+filegroup {
+ name: "adte-owners-files",
+ srcs: [
+ "OWNERS_ADTE_TEAM",
+ "OWNERS",
+ ],
+}
diff --git a/OWNERS_ADTE_TEAM b/OWNERS_ADTE_TEAM
index 76e26bc..12ca660 100644
--- a/OWNERS_ADTE_TEAM
+++ b/OWNERS_ADTE_TEAM
@@ -2,6 +2,7 @@
davidjames@google.com
hwj@google.com
hzalek@google.com
+ihcinihsdk@google.com
kevindagostino@google.com
liuyg@google.com
lucafarsi@google.com
diff --git a/atest/Android.bp b/atest/Android.bp
index 7337b1d..c5fda1f 100644
--- a/atest/Android.bp
+++ b/atest/Android.bp
@@ -68,6 +68,7 @@
defaults: ["atest_binary_defaults"],
main: "atest_main.py",
data: [
+ ":adte-owners-files",
":atest_flag_list_for_completion",
":atest_log_uploader",
],
diff --git a/atest/arg_parser.py b/atest/arg_parser.py
index 3250525..05d7720 100644
--- a/atest/arg_parser.py
+++ b/atest/arg_parser.py
@@ -20,6 +20,7 @@
from atest import bazel_mode
from atest import constants
+from atest import rollout_control
from atest.atest_utils import BuildOutputMode
@@ -119,7 +120,7 @@
)
parser.add_argument(
'--bazel-mode',
- default=True,
+ default=not rollout_control.disable_bazel_mode_by_default.is_enabled(),
action='store_true',
help='Run tests using Bazel (default: True).',
)
diff --git a/atest/atest_enum.py b/atest/atest_enum.py
index 33b9861..d44fdf2 100644
--- a/atest/atest_enum.py
+++ b/atest/atest_enum.py
@@ -117,6 +117,10 @@
IS_PLOCATEDB_LOCKED = 63
# Device update duration
DEVICE_UPDATE_MS = 64
+ # The ID of the feature that is controlled by rollout control. Positive value
+ # means the feature is enabled, negative value means disabled.
+ ROLLOUT_CONTROLLED_FEATURE_ID = 65
+ ROLLOUT_CONTROLLED_FEATURE_ID_OVERRIDE = 66
@unique
diff --git a/atest/atest_utils.py b/atest/atest_utils.py
index a893d60..d50e573 100644
--- a/atest/atest_utils.py
+++ b/atest/atest_utils.py
@@ -27,7 +27,9 @@
import fnmatch
import hashlib
import html
-import importlib
+import importlib.resources
+import importlib.util
+import io
import itertools
import json
import logging
@@ -40,6 +42,7 @@
import shutil
import subprocess
import sys
+import threading
from threading import Thread
import traceback
from typing import Any, Dict, IO, List, Set, Tuple
@@ -54,7 +57,7 @@
from atest.metrics import metrics_utils
from atest.tf_proto import test_record_pb2
-_BUILD_OUTPUT_ROLLING_LINES = 6
+DEFAULT_OUTPUT_ROLLING_LINES = 6
_BASH_CLEAR_PREVIOUS_LINE_CODE = '\033[F\033[K'
_BASH_RESET_CODE = '\033[0m'
DIST_OUT_DIR = Path(
@@ -270,44 +273,113 @@
return output
-def _stream_io_output(io_input: IO, io_output: IO, max_lines=None):
+def stream_io_output(
+ io_input: IO,
+ max_lines=None,
+ full_output_receiver: IO = None,
+ io_output: IO = None,
+ is_io_output_atty=None,
+):
"""Stream an IO output with max number of rolling lines to display if set.
Args:
input: The file-like object to read the output from.
- output: The file-like object to write the output to.
max_lines: The maximum number of rolling lines to display. If None, all
lines will be displayed.
+ full_output_receiver: Optional io to receive the full output.
+ io_output: The file-like object to write the output to.
+ is_io_output_atty: Whether the io_output is a TTY.
"""
- print('\n----------------------------------------------------')
- term_width, _ = get_terminal_size()
- full_output = []
- last_lines = None if not max_lines else deque(maxlen=max_lines)
- last_number_of_lines = 0
- for line in iter(io_input.readline, ''):
- full_output.append(line)
- line = line.rstrip()
- if last_lines is None:
+ if io_output is None:
+ io_output = _original_sys_stdout
+ if is_io_output_atty is None:
+ is_io_output_atty = _has_colors(io_output)
+ if not max_lines or not is_io_output_atty:
+ for line in iter(io_input.readline, ''):
+ if not line:
+ break
+ if full_output_receiver is not None:
+ full_output_receiver.write(
+ line if isinstance(line, str) else line.decode('utf-8')
+ )
io_output.write(line)
- io_output.write('\n')
io_output.flush()
- continue
+ return
+
+ term_width, _ = get_terminal_size()
+ last_lines = deque(maxlen=max_lines)
+ is_rolling = True
+
+ def reset_output():
+ if is_rolling and last_lines:
+ io_output.write(_BASH_CLEAR_PREVIOUS_LINE_CODE * (len(last_lines) + 2))
+
+ def write_output(new_lines: list[str]):
+ if not is_rolling:
+ return
+ last_lines.extend(new_lines)
+ lines = ['========== Rolling subprocess output ==========']
+ lines.extend(last_lines)
+ lines.append('-----------------------------------------------')
+ io_output.write('\n'.join(lines))
+ io_output.write('\n')
+ io_output.flush()
+
+ original_stdout = sys.stdout
+ original_stderr = sys.stderr
+
+ lock = threading.Lock()
+
+ class SafeStdout:
+
+ def __init__(self):
+ self._buffers = []
+
+ def write(self, buf: str) -> None:
+ if len(buf) == 1 and buf[0] == '\n' and self._buffers:
+ with lock:
+ reset_output()
+ original_stdout.write(''.join(self._buffers))
+ original_stdout.write('\n')
+ original_stdout.flush()
+ write_output([])
+ self._buffers.clear()
+ else:
+ self._buffers.append(buf)
+
+ def flush(self) -> None:
+ original_stdout.flush()
+
+ sys.stdout = SafeStdout()
+ sys.stderr = sys.stdout
+
+ for line in iter(io_input.readline, ''):
+ if not line:
+ break
+ line = line.decode('utf-8') if isinstance(line, bytes) else line
+ if full_output_receiver is not None:
+ full_output_receiver.write(line)
+ line = line.rstrip().replace('\t', ' ')
# Split the line if it's longer than the terminal width
wrapped_lines = (
[line]
if len(line) <= term_width
else [line[i : i + term_width] for i in range(0, len(line), term_width)]
)
- last_lines.extend(wrapped_lines)
- io_output.write(_BASH_CLEAR_PREVIOUS_LINE_CODE * last_number_of_lines)
- io_output.write('\n'.join(last_lines))
- io_output.write('\n')
+ with lock:
+ reset_output()
+ write_output(wrapped_lines)
+
+ with lock:
+ reset_output()
+ is_rolling = False
+ io_output.write(_BASH_RESET_CODE)
io_output.flush()
- last_number_of_lines = len(last_lines)
+
+ sys.stdout = original_stdout
+ sys.stderr = original_stderr
+
io_input.close()
- io_output.write(_BASH_RESET_CODE)
- io_output.flush()
- print('----------------------------------------------------')
def run_limited_output(
@@ -336,12 +408,18 @@
start_new_session=start_new_session,
text=True,
) as proc:
- _stream_io_output(
- proc.stdout, _original_sys_stdout, _BUILD_OUTPUT_ROLLING_LINES
+ full_output_receiver = io.StringIO()
+ stream_io_output(
+ proc.stdout,
+ DEFAULT_OUTPUT_ROLLING_LINES,
+ full_output_receiver,
+ _original_sys_stdout,
)
returncode = proc.wait()
if returncode:
- raise subprocess.CalledProcessError(returncode, cmd, full_output)
+ raise subprocess.CalledProcessError(
+ returncode, cmd, full_output_receiver.getvalue()
+ )
def get_build_out_dir(*joinpaths) -> Path:
@@ -533,6 +611,11 @@
return all((len(args.tests) == 1, args.tests[0][0] == ':'))
+def is_atty_terminal() -> bool:
+ """Check if the current process is running in a TTY."""
+ return getattr(_original_sys_stdout, 'isatty', lambda: False)()
+
+
def _has_colors(stream):
"""Check the output stream is colorful.
@@ -852,7 +935,7 @@
# Save test_info to files.
try:
with open(cache_path, 'wb') as test_info_cache_file:
- logging.debug('Saving cache %s.', cache_path)
+ logging.debug('Saving cache for %s as %s.', test_reference, cache_path)
pickle.dump(test_infos, test_info_cache_file, protocol=2)
except (pickle.PicklingError, TypeError, IOError) as err:
# Won't break anything, just log this error, and collect the exception
@@ -876,7 +959,7 @@
cache_file = get_test_info_cache_path(test_reference, cache_root)
if os.path.isfile(cache_file):
- logging.debug('Loading cache %s.', cache_file)
+ logging.debug('Loading cache %s from %s.', test_reference, cache_file)
try:
with open(cache_file, 'rb') as config_dictionary_file:
return pickle.load(config_dictionary_file, encoding='utf-8')
diff --git a/atest/atest_utils_unittest.py b/atest/atest_utils_unittest.py
index 0b8d6a3..a3eac85 100755
--- a/atest/atest_utils_unittest.py
+++ b/atest/atest_utils_unittest.py
@@ -66,6 +66,7 @@
----------------------------
"""
+
class StreamIoOutputTest(unittest.TestCase):
"""Class that tests the _stream_io_output function."""
@@ -76,9 +77,13 @@
io_input.seek(0)
io_output = StringIO()
- atest_utils._stream_io_output(io_input, io_output, max_lines=None)
+ atest_utils.stream_io_output(
+ io_input, max_lines=None, io_output=io_output, is_io_output_atty=True
+ )
- self.assertNotIn(atest_utils._BASH_CLEAR_PREVIOUS_LINE_CODE, io_output.getvalue())
+ self.assertNotIn(
+ atest_utils._BASH_CLEAR_PREVIOUS_LINE_CODE, io_output.getvalue()
+ )
@mock.patch.object(atest_utils, 'get_terminal_size', return_value=(5, -1))
def test_stream_io_output_wrap_long_lines(self, _):
@@ -88,7 +93,9 @@
io_input.seek(0)
io_output = StringIO()
- atest_utils._stream_io_output(io_input, io_output, max_lines=10)
+ atest_utils.stream_io_output(
+ io_input, max_lines=10, io_output=io_output, is_io_output_atty=True
+ )
self.assertIn('11111\n11111', io_output.getvalue())
@@ -100,10 +107,16 @@
io_input.seek(0)
io_output = StringIO()
- atest_utils._stream_io_output(io_input, io_output, max_lines=2)
+ atest_utils.stream_io_output(
+ io_input, max_lines=2, io_output=io_output, is_io_output_atty=True
+ )
self.assertIn(
- atest_utils._BASH_CLEAR_PREVIOUS_LINE_CODE * 2 + '2\n3\n',
+ '2\n3\n',
+ io_output.getvalue(),
+ )
+ self.assertNotIn(
+ '1\n2\n3\n',
io_output.getvalue(),
)
@@ -115,10 +128,44 @@
io_input.seek(0)
io_output = StringIO()
- atest_utils._stream_io_output(io_input, io_output, max_lines=4)
+ atest_utils.stream_io_output(
+ io_input, max_lines=4, io_output=io_output, is_io_output_atty=True
+ )
self.assertIn(
- atest_utils._BASH_CLEAR_PREVIOUS_LINE_CODE * 2 + '1\n2\n3\n',
+ '1\n2\n3\n',
+ io_output.getvalue(),
+ )
+
+ @mock.patch.object(atest_utils, 'get_terminal_size', return_value=(5, -1))
+ def test_stream_io_output_no_lines_written_no_lines_cleared(self, _):
+ """Test when nothing is written, no lines are cleared."""
+ io_input = StringIO()
+ io_output = StringIO()
+
+ atest_utils.stream_io_output(
+ io_input, max_lines=2, io_output=io_output, is_io_output_atty=True
+ )
+
+ self.assertNotIn(
+ atest_utils._BASH_CLEAR_PREVIOUS_LINE_CODE,
+ io_output.getvalue(),
+ )
+
+ @mock.patch.object(atest_utils, 'get_terminal_size', return_value=(5, -1))
+ def test_stream_io_output_replace_tab_with_spaces(self, _):
+ """Test when line exceeds max_lines, the previous lines are cleared."""
+ io_input = StringIO()
+ io_input.write('1\t2')
+ io_input.seek(0)
+ io_output = StringIO()
+
+ atest_utils.stream_io_output(
+ io_input, max_lines=2, io_output=io_output, is_io_output_atty=True
+ )
+
+ self.assertNotIn(
+ '\t',
io_output.getvalue(),
)
diff --git a/atest/bazel/resources/rules/tradefed_test.bzl b/atest/bazel/resources/rules/tradefed_test.bzl
index eca0fc1..7fbc750 100644
--- a/atest/bazel/resources/rules/tradefed_test.bzl
+++ b/atest/bazel/resources/rules/tradefed_test.bzl
@@ -14,10 +14,6 @@
"""Rules used to run tests using Tradefed."""
-load("//bazel/rules:platform_transitions.bzl", "device_transition", "host_transition")
-load("//bazel/rules:tradefed_test_aspects.bzl", "soong_prebuilt_tradefed_test_aspect")
-load("//bazel/rules:tradefed_test_dependency_info.bzl", "TradefedTestDependencyInfo")
-load("//bazel/rules:common_settings.bzl", "BuildSettingInfo")
load(
"//:constants.bzl",
"aapt2_label",
@@ -32,7 +28,11 @@
"tradefed_test_framework_label",
"vts_core_tradefed_harness_label",
)
+load("//bazel/rules:common_settings.bzl", "BuildSettingInfo")
load("//bazel/rules:device_test.bzl", "device_test")
+load("//bazel/rules:platform_transitions.bzl", "device_transition", "host_transition")
+load("//bazel/rules:tradefed_test_aspects.bzl", "soong_prebuilt_tradefed_test_aspect")
+load("//bazel/rules:tradefed_test_dependency_info.bzl", "TradefedTestDependencyInfo")
TradefedTestInfo = provider(
doc = "Info about a Tradefed test module",
@@ -381,7 +381,7 @@
# Since `vts-core-tradefed-harness` includes `compatibility-tradefed`, we
# will exclude `compatibility-tradefed` if `vts-core-tradefed-harness` exists.
if vts_core_tradefed_harness_label in all_tradefed_deps:
- all_tradefed_deps.pop(compatibility_tradefed_label, default = None)
+ all_tradefed_deps.pop(compatibility_tradefed_label)
return all_tradefed_deps.keys()
@@ -424,18 +424,16 @@
def _configure_python_toolchain(ctx):
py_toolchain_info = ctx.toolchains[_PY_TOOLCHAIN]
- py2_interpreter = py_toolchain_info.py2_runtime.interpreter
py3_interpreter = py_toolchain_info.py3_runtime.interpreter
# Create `python` and `python3` symlinks in the runfiles tree and add them
# to the executable path. This is required because scripts reference these
# commands in their shebang line.
py_runfiles = ctx.runfiles(symlinks = {
- "/".join([py2_interpreter.dirname, "python"]): py2_interpreter,
+ "/".join([py3_interpreter.dirname, "python"]): py3_interpreter,
"/".join([py3_interpreter.dirname, "python3"]): py3_interpreter,
})
py_paths = [
- _BAZEL_WORK_DIR + py2_interpreter.dirname,
_BAZEL_WORK_DIR + py3_interpreter.dirname,
]
return (py_paths, py_runfiles)
diff --git a/atest/integration_tests/atest_dry_run_diff_tests.py b/atest/integration_tests/atest_dry_run_diff_tests.py
index 5031b5c..3bab945 100644
--- a/atest/integration_tests/atest_dry_run_diff_tests.py
+++ b/atest/integration_tests/atest_dry_run_diff_tests.py
@@ -154,7 +154,7 @@
map(
lambda result: result.get_atest_log_values_from_prefix(
atest_integration_test.DRY_RUN_COMMAND_LOG_PREFIX
- )[0],
+ ),
cmd_results_prod,
)
),
@@ -165,7 +165,7 @@
map(
lambda result: result.get_atest_log_values_from_prefix(
atest_integration_test.DRY_RUN_COMMAND_LOG_PREFIX
- )[0],
+ ),
cmd_results_dev,
)
),
@@ -209,21 +209,29 @@
with self.subTest(
name=f'{usages[idx].command}_runner_cmd_has_same_elements'
):
- sanitized_runner_cmd_prod = (
- atest_integration_test.sanitize_runner_command(runner_cmd_prod[idx])
- )
- sanitized_runner_cmd_dev = (
- atest_integration_test.sanitize_runner_command(runner_cmd_dev[idx])
- )
self.assertEqual(
- set(sanitized_runner_cmd_prod.split(' ')),
- set(sanitized_runner_cmd_dev.split(' ')),
- 'Runner command mismatch for command:'
- f' {usages[idx].command}.\nProd:\n'
- f' {sanitized_runner_cmd_prod}\nDev:\n{sanitized_runner_cmd_dev}\n'
- f' {impact_str}',
+ len(runner_cmd_prod[idx]),
+ len(runner_cmd_dev[idx]),
+ 'Nummber of runner commands mismatch for command:'
+ ' {usages[idx].command}.',
)
+ for cmd_idx in range(len(runner_cmd_prod[idx])):
+ sanitized_runner_cmd_prod = (
+ atest_integration_test.sanitize_runner_command(runner_cmd_prod[idx][cmd_idx])
+ )
+ sanitized_runner_cmd_dev = (
+ atest_integration_test.sanitize_runner_command(runner_cmd_dev[idx][cmd_idx])
+ )
+ self.assertEqual(
+ set(sanitized_runner_cmd_prod.split(' ')),
+ set(sanitized_runner_cmd_dev.split(' ')),
+ 'Runner command mismatch for command:'
+ f' {usages[idx].command}.\nProd:\n'
+ f' {sanitized_runner_cmd_prod}\nDev:\n{sanitized_runner_cmd_dev}\n'
+ f' {impact_str}',
+ )
+
# A copy of the list of atest commands tested in the command verification tests.
_default_input_commands = [
diff --git a/atest/rollout_control.py b/atest/rollout_control.py
new file mode 100644
index 0000000..69d3421
--- /dev/null
+++ b/atest/rollout_control.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+# Copyright 2024, The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Rollout control for Atest features."""
+
+import functools
+import getpass
+import hashlib
+import importlib.resources
+import logging
+import os
+from atest import atest_enum
+from atest.metrics import metrics
+
+
+@functools.cache
+def _get_project_owners() -> list[str]:
+ """Returns the owners of the feature."""
+ owners = []
+ try:
+ with importlib.resources.as_file(
+ importlib.resources.files('atest').joinpath('OWNERS')
+ ) as version_file_path:
+ owners.extend(version_file_path.read_text(encoding='utf-8').splitlines())
+ except (ModuleNotFoundError, FileNotFoundError) as e:
+ logging.error(e)
+ try:
+ with importlib.resources.as_file(
+ importlib.resources.files('atest').joinpath('OWNERS_ADTE_TEAM')
+ ) as version_file_path:
+ owners.extend(version_file_path.read_text(encoding='utf-8').splitlines())
+ except (ModuleNotFoundError, FileNotFoundError) as e:
+ logging.error(e)
+ return [line.split('@')[0] for line in owners if '@google.com' in line]
+
+
+class RolloutControlledFeature:
+ """Base class for Atest features under rollout control."""
+
+ def __init__(
+ self,
+ name: str,
+ rollout_percentage: float,
+ env_control_flag: str,
+ feature_id: int = None,
+ owners: list[str] | None = None,
+ ):
+ """Initializes the object.
+
+ Args:
+ name: The name of the feature.
+ rollout_percentage: The percentage of users to enable the feature for.
+ The value should be in [0, 100].
+ env_control_flag: The environment variable name to override the feature
+ enablement. When set, 'true' or '1' means enable, other values means
+ disable.
+ feature_id: The ID of the feature that is controlled by rollout control
+ for metric collection purpose. Must be a positive integer.
+ owners: The owners of the feature. If not provided, the owners of the
+ feature will be read from OWNERS file.
+ """
+ if rollout_percentage < 0 or rollout_percentage > 100:
+ raise ValueError(
+ 'Rollout percentage must be in [0, 100]. Got %s instead.'
+ % rollout_percentage
+ )
+ if feature_id is not None and feature_id <= 0:
+ raise ValueError(
+ 'Feature ID must be a positive integer. Got %s instead.' % feature_id
+ )
+ if owners is None:
+ owners = _get_project_owners()
+ self._name = name
+ self._rollout_percentage = rollout_percentage
+ self._env_control_flag = env_control_flag
+ self._feature_id = feature_id
+ self._owners = owners
+
+ def _check_env_control_flag(self) -> bool | None:
+ """Checks the environment variable to override the feature enablement.
+
+ Returns:
+ True if the feature is enabled, False if disabled, None if not set.
+ """
+ if self._env_control_flag not in os.environ:
+ return None
+ return os.environ[self._env_control_flag] in ('TRUE', 'True', 'true', '1')
+
+ @functools.cache
+ def is_enabled(self, username: str | None = None) -> bool:
+ """Checks whether the current feature is enabled for the user.
+
+ Args:
+ username: The username to check the feature enablement for. If not
+ provided, the current user's username will be used.
+
+ Returns:
+ True if the feature is enabled for the user, False otherwise.
+ """
+ override_flag_value = self._check_env_control_flag()
+ if override_flag_value is not None:
+ logging.debug(
+ 'Feature %s is %s by env variable %s.',
+ self._name,
+ 'enabled' if override_flag_value else 'disabled',
+ self._env_control_flag,
+ )
+ if self._feature_id:
+ metrics.LocalDetectEvent(
+ detect_type=atest_enum.DetectType.ROLLOUT_CONTROLLED_FEATURE_ID_OVERRIDE,
+ result=self._feature_id
+ if override_flag_value
+ else -self._feature_id,
+ )
+ return override_flag_value
+
+ if self._rollout_percentage == 100:
+ return True
+
+ if username is None:
+ username = getpass.getuser()
+
+ if not username:
+ logging.debug(
+ 'Unable to determine the username. Disabling the feature %s.',
+ self._name,
+ )
+ return False
+
+ is_enabled = username in self._owners
+
+ if not is_enabled:
+ if self._rollout_percentage == 0:
+ return False
+
+ hash_object = hashlib.sha256()
+ hash_object.update((username + ' ' + self._name).encode('utf-8'))
+ is_enabled = (
+ int(hash_object.hexdigest(), 16) % 100 < self._rollout_percentage
+ )
+
+ logging.debug(
+ 'Feature %s is %s for user %s.',
+ self._name,
+ 'enabled' if is_enabled else 'disabled',
+ username,
+ )
+
+ if self._feature_id:
+ metrics.LocalDetectEvent(
+ detect_type=atest_enum.DetectType.ROLLOUT_CONTROLLED_FEATURE_ID,
+ result=self._feature_id if is_enabled else -self._feature_id,
+ )
+
+ if is_enabled and self._rollout_percentage < 100:
+ print(
+ '\nYou are among the first %s%% of users to receive the feature "%s".'
+ ' If you experience any issues, you can disable the feature by'
+ ' setting the environment variable "%s" to false, and the atest team'
+ ' will be notified.\n'
+ % (self._rollout_percentage, self._name, self._env_control_flag)
+ )
+
+ return is_enabled
+
+
+disable_bazel_mode_by_default = RolloutControlledFeature(
+ name='Bazel mode disabled by default',
+ rollout_percentage=5,
+ env_control_flag='DISABLE_BAZEL_MODE_BY_DEFAULT',
+ feature_id=1,
+)
+
+rolling_tf_subprocess_output = RolloutControlledFeature(
+ name='Rolling TradeFed subprocess output',
+ rollout_percentage=5,
+ env_control_flag='ROLLING_TF_SUBPROCESS_OUTPUT',
+ feature_id=2,
+)
diff --git a/atest/rollout_control_unittest.py b/atest/rollout_control_unittest.py
new file mode 100644
index 0000000..ca000d0
--- /dev/null
+++ b/atest/rollout_control_unittest.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+# Copyright 2024, The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+from unittest import mock
+from atest import rollout_control
+
+
+class RolloutControlledFeatureUnittests(unittest.TestCase):
+
+ def test_is_enabled_username_hash_is_greater_than_rollout_percentage_returns_false(
+ self,
+ ):
+ sut = rollout_control.RolloutControlledFeature(
+ name='test_feature',
+ rollout_percentage=66,
+ env_control_flag='TEST_FEATURE',
+ )
+
+ self.assertFalse(sut.is_enabled('username'))
+
+ def test_is_enabled_username_hash_is_equal_to_rollout_percentage_returns_false(
+ self,
+ ):
+ sut = rollout_control.RolloutControlledFeature(
+ name='test_feature',
+ rollout_percentage=67,
+ env_control_flag='TEST_FEATURE',
+ )
+
+ self.assertFalse(sut.is_enabled('username'))
+
+ def test_is_enabled_username_hash_is_less_or_equal_than_rollout_percentage_returns_true(
+ self,
+ ):
+ sut = rollout_control.RolloutControlledFeature(
+ name='test_feature',
+ rollout_percentage=68,
+ env_control_flag='TEST_FEATURE',
+ )
+
+ self.assertTrue(sut.is_enabled('username'))
+
+ def test_is_enabled_username_undetermined_returns_false(self):
+ sut = rollout_control.RolloutControlledFeature(
+ name='test_feature',
+ rollout_percentage=99,
+ env_control_flag='TEST_FEATURE',
+ )
+
+ self.assertFalse(sut.is_enabled(''))
+
+ def test_is_enabled_flag_set_to_true_returns_true(self):
+ sut = rollout_control.RolloutControlledFeature(
+ name='test_feature',
+ rollout_percentage=0,
+ env_control_flag='TEST_FEATURE',
+ )
+
+ with mock.patch.dict('os.environ', {'TEST_FEATURE': 'true'}):
+ self.assertTrue(sut.is_enabled())
+
+ def test_is_enabled_flag_set_to_1_returns_true(self):
+ sut = rollout_control.RolloutControlledFeature(
+ name='test_feature',
+ rollout_percentage=0,
+ env_control_flag='TEST_FEATURE',
+ )
+
+ with mock.patch.dict('os.environ', {'TEST_FEATURE': '1'}):
+ self.assertTrue(sut.is_enabled())
+
+ def test_is_enabled_flag_set_to_false_returns_false(self):
+ sut = rollout_control.RolloutControlledFeature(
+ name='test_feature',
+ rollout_percentage=100,
+ env_control_flag='TEST_FEATURE',
+ )
+
+ with mock.patch.dict('os.environ', {'TEST_FEATURE': 'false'}):
+ self.assertFalse(sut.is_enabled())
+
+ def test_is_enabled_is_owner_returns_true(self):
+ sut = rollout_control.RolloutControlledFeature(
+ name='test_feature',
+ rollout_percentage=0,
+ env_control_flag='TEST_FEATURE',
+ owners=['owner_name'],
+ )
+
+ self.assertFalse(sut.is_enabled('name'))
+ self.assertTrue(sut.is_enabled('owner_name'))
diff --git a/atest/test_finders/module_finder.py b/atest/test_finders/module_finder.py
index 7775668..bbb0d06 100644
--- a/atest/test_finders/module_finder.py
+++ b/atest/test_finders/module_finder.py
@@ -107,7 +107,7 @@
Args:
test: TestInfo to update with vts10 specific details.
- Return:
+ Returns:
TestInfo that is ready for the vts10 test runner.
"""
test.test_runner = self._VTS_TEST_RUNNER
@@ -197,7 +197,7 @@
Args:
test: TestInfo that has been filled out by a find method.
- Return:
+ Returns:
TestInfo that has been modified as needed and return None if
this module can't be found in the module_info.
"""
@@ -237,7 +237,9 @@
logging.debug(
'Add %s to build targets...', ', '.join(artifact_map.keys())
)
- test.artifacts = [apk for p in artifact_map.values() for apk in p]
+ test.artifacts = []
+ for p in artifact_map.values():
+ test.artifacts += p
logging.debug('Will install target APK: %s\n', test.artifacts)
metrics.LocalDetectEvent(
detect_type=DetectType.FOUND_TARGET_ARTIFACTS,
@@ -292,11 +294,6 @@
for module_path in self.module_info.get_paths(module_name):
mod_dir = module_path.replace('/', '-')
targets.add(constants.MODULES_IN + mod_dir)
- # (b/156457698) Force add vts_kernel_ltp_tests as build target if our
- # test belongs to REQUIRED_LTP_TEST_MODULES due to required_module
- # option not working for sh_test in soong.
- if module_name in constants.REQUIRED_LTP_TEST_MODULES:
- targets.add('vts_kernel_ltp_tests')
# (b/184567849) Force adding module_name as a build_target. This will
# allow excluding MODULES-IN-* and prevent from missing build targets.
if module_name and self.module_info.is_module(module_name):
@@ -345,14 +342,17 @@
# Double check if below section is needed.
if (
not self.module_info.is_auto_gen_test_config(module_name)
- and len(test_configs) > 0
+ and test_configs
):
return test_configs
return [rel_config] if rel_config else []
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
- def _get_test_info_filter(self, path, methods, **kwargs):
+ def _get_test_info_filter(
+ self, path, methods, rel_module_dir=None, class_name=None,
+ is_native_test=False
+ ):
"""Get test info filter.
Args:
@@ -361,20 +361,21 @@
rel_module_dir: Optional. A string of the module dir no-absolute to
root.
class_name: Optional. A string of the class name.
- is_native_test: Optional. A boolean variable of whether to search for a
- native test or not.
+ is_native_test: Optional. A boolean variable of whether to search for
+ a native test or not.
Returns:
A set of test info filter.
"""
_, file_name = test_finder_utils.get_dir_path_and_filename(path)
ti_filter = frozenset()
- if os.path.isfile(path) and kwargs.get('is_native_test', None):
+ if os.path.isfile(path) and is_native_test:
class_info = test_finder_utils.get_cc_class_info(path)
ti_filter = frozenset([
test_info.TestFilter(
test_filter_utils.get_cc_filter(
- class_info, kwargs.get('class_name', '*'), methods
+ class_info,
+ class_name if class_name is not None else '*', methods
),
frozenset(),
)
@@ -404,14 +405,13 @@
)
ti_filter = frozenset(cc_filters)
# If input path is a folder and have class_name information.
- elif not file_name and kwargs.get('class_name', None):
+ elif not file_name and class_name:
ti_filter = frozenset(
- [test_info.TestFilter(kwargs.get('class_name', None), methods)]
+ [test_info.TestFilter(class_name, methods)]
)
# Path to non-module dir, treat as package.
- elif not file_name and kwargs.get(
- 'rel_module_dir', None
- ) != os.path.relpath(path, self.root_dir):
+ elif not file_name and rel_module_dir != os.path.relpath(
+ path, self.root_dir):
dir_items = [os.path.join(path, f) for f in os.listdir(path)]
for dir_item in dir_items:
if constants.JAVA_EXT_RE.match(dir_item):
@@ -784,7 +784,7 @@
Args:
package: A string of the package name.
module_name: Optional. A string of the module name.
- ref_config: Optional. A string of rel path of config.
+ rel_config: Optional. A string of rel path of config.
Returns:
A list of populated TestInfo namedtuple if found, else None.
@@ -868,7 +868,6 @@
"""
logging.debug('Finding test by path: %s', rel_path)
path, methods = test_filter_utils.split_methods(rel_path)
- # TODO: See if this can be generalized and shared with methods above
# create absolute path from cwd and remove symbolic links
path = os.path.realpath(path)
if not os.path.exists(path):
@@ -1026,16 +1025,16 @@
Args:
user_input: the target module name for fuzzy searching.
- Return:
+ Returns:
A list of guessed modules.
"""
modules_with_ld = self.get_testable_modules_with_ld(
user_input, ld_range=constants.LD_RANGE
)
guessed_modules = []
- for _distance, _module in modules_with_ld:
- if _distance <= abs(constants.LD_RANGE):
- guessed_modules.append(_module)
+ for distance_, module_ in modules_with_ld:
+ if distance_ <= abs(constants.LD_RANGE):
+ guessed_modules.append(module_)
return guessed_modules
def find_test_by_config_name(self, config_name):
diff --git a/atest/test_runners/atest_tf_test_runner.py b/atest/test_runners/atest_tf_test_runner.py
index f227c4a..87e1046 100644
--- a/atest/test_runners/atest_tf_test_runner.py
+++ b/atest/test_runners/atest_tf_test_runner.py
@@ -31,6 +31,7 @@
import select
import shutil
import socket
+import threading
import time
from typing import Any, Dict, List, Set, Tuple
@@ -40,6 +41,7 @@
from atest import constants
from atest import module_info
from atest import result_reporter
+from atest import rollout_control
from atest.atest_enum import DetectType, ExitCode
from atest.coverage import coverage
from atest.logstorage import logstorage_utils
@@ -399,12 +401,26 @@
run_cmds = self.generate_run_commands(
test_infos, extra_args, server.getsockname()[1]
)
+ is_rolling_output = (
+ rollout_control.rolling_tf_subprocess_output.is_enabled()
+ and not extra_args.get(constants.VERBOSE, False)
+ and atest_utils.is_atty_terminal()
+ )
+
logging.debug('Running test: %s', run_cmds[0])
subproc = self.run(
run_cmds[0],
output_to_stdout=extra_args.get(constants.VERBOSE, False),
env_vars=self.generate_env_vars(extra_args),
+ rolling_output_lines=is_rolling_output,
)
+
+ if is_rolling_output:
+ threading.Thread(
+ target=atest_utils.stream_io_output,
+ args=(subproc.stdout, atest_utils.DEFAULT_OUTPUT_ROLLING_LINES),
+ ).start()
+
self.handle_subprocess(
subproc,
partial(self._start_monitor, server, subproc, reporter, extra_args),
diff --git a/atest/test_runners/test_runner_base.py b/atest/test_runners/test_runner_base.py
index 499cb1f..927960a 100644
--- a/atest/test_runners/test_runner_base.py
+++ b/atest/test_runners/test_runner_base.py
@@ -79,6 +79,7 @@
"""Init stuff for base class."""
self.results_dir = results_dir
self.test_log_file = None
+ self._subprocess_stdout = None
if not self.NAME:
raise atest_error.NoTestRunnerName('Class var NAME is not defined.')
if not self.EXECUTABLE:
@@ -116,7 +117,13 @@
"""Checks whether this runner requires device update."""
return False
- def run(self, cmd, output_to_stdout=False, env_vars=None):
+ def run(
+ self,
+ cmd,
+ output_to_stdout=False,
+ env_vars=None,
+ rolling_output_lines=False,
+ ):
"""Shell out and execute command.
Args:
@@ -127,20 +134,34 @@
reporter to print the test results. Set to True to see the output of
the cmd. This would be appropriate for verbose runs.
env_vars: Environment variables passed to the subprocess.
+ rolling_output_lines: If True, the subprocess output will be streamed
+ with rolling lines when output_to_stdout is False.
"""
- if not output_to_stdout:
- self.test_log_file = tempfile.NamedTemporaryFile(
- mode='w', dir=self.results_dir, delete=True
- )
logging.debug('Executing command: %s', cmd)
- return subprocess.Popen(
- cmd,
- start_new_session=True,
- shell=True,
- stderr=subprocess.STDOUT,
- stdout=self.test_log_file,
- env=env_vars,
- )
+ if rolling_output_lines:
+ proc = subprocess.Popen(
+ cmd,
+ start_new_session=True,
+ shell=True,
+ stderr=subprocess.STDOUT,
+ stdout=None if output_to_stdout else subprocess.PIPE,
+ env=env_vars,
+ )
+ self._subprocess_stdout = proc.stdout
+ return proc
+ else:
+ if not output_to_stdout:
+ self.test_log_file = tempfile.NamedTemporaryFile(
+ mode='w', dir=self.results_dir, delete=True
+ )
+ return subprocess.Popen(
+ cmd,
+ start_new_session=True,
+ shell=True,
+ stderr=subprocess.STDOUT,
+ stdout=self.test_log_file,
+ env=env_vars,
+ )
# pylint: disable=broad-except
def handle_subprocess(self, subproc, func):
@@ -165,11 +186,15 @@
# we have to save it above.
logging.debug('Subproc already terminated, skipping')
finally:
- if self.test_log_file:
+ full_output = ''
+ if self._subprocess_stdout:
+ full_output = self._subprocess_stdout.read()
+ elif self.test_log_file:
with open(self.test_log_file.name, 'r') as f:
- intro_msg = 'Unexpected Issue. Raw Output:'
- print(atest_utils.mark_red(intro_msg))
- print(f.read())
+ full_output = f.read()
+ if full_output:
+ print(atest_utils.mark_red('Unexpected Issue. Raw Output:'))
+ print(full_output)
# Ignore socket.recv() raising due to ctrl-c
if not error.args or error.args[0] != errno.EINTR:
raise error