diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83357dce..a7c40f49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,19 +6,25 @@ on: - 'v*' workflow_dispatch: +permissions: {} + jobs: build: runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.11" - name: Setup Bazel - uses: bazel-contrib/setup-bazel@0.19.0 + uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 with: bazelisk-cache: true disk-cache: true @@ -29,7 +35,7 @@ jobs: bazel build //:android_env_wheel - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: releases path: bazel-bin/**/*.whl @@ -42,10 +48,10 @@ jobs: id-token: write steps: - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: releases path: dist - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1 diff --git a/BUILD.bazel b/BUILD.bazel index 5944f881..3d88ebfc 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1,6 +1,9 @@ load("@rules_python//python:defs.bzl", "py_binary") -exports_files(["requirements.txt"]) +exports_files([ + "README.md", + "requirements.txt", +]) alias( name = "android_env_wheel", diff --git a/MODULE.bazel b/MODULE.bazel index 75d51548..69e5b00a 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -48,6 +48,7 @@ pip.parse( use_repo(pip, "pip") # Hermetic TypeScript and JavaScript Toolchain +bazel_dep(name = "aspect_rules_esbuild", version = "0.25.1") bazel_dep(name = "aspect_rules_ts", version = "3.5.0") bazel_dep(name = "aspect_rules_js", version = "2.2.0") bazel_dep(name = "rules_nodejs", version = "6.3.3") diff --git a/README.md b/README.md index 65d38821..483ff8d3 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,8 @@ Install AndroidEnv from PyPI: $ python3 -m pip install android-env ``` -Or clone the repository from `main` and build/test with Bazel: +Or clone the repository from `main` and build/test with Bazel (ensure +`ANDROID_HOME` points to a local Android SDK installation): ```shell $ git clone https://github.com/google-deepmind/android_env/ @@ -142,11 +143,17 @@ with android_device_loader.load_android_device(config) as device: ### 3. Launch the Interactive Web Playground Explore the device screen, inspect live accessibility trees, and run ADB -commands from your browser: +commands from your browser (after `pip install "android-env[playground]"`): ```shell -$ bazel run //android_env/playground:launch -- \ - --config=android_env/configs/emulator_config.py +$ android-env-playground --serial=emulator-5554 +``` + +Or launch a local AVD from a source checkout with Bazel: + +```shell +$ bazel run //android_env/playground:playground -- \ + --config=android_env/playground/configs/emulator_config.py ``` ### 4. Reinforcement Learning (`dm_env`) diff --git a/README.zh.md b/README.zh.md index 84480549..95c8c503 100644 --- a/README.zh.md +++ b/README.zh.md @@ -50,7 +50,7 @@ $ python3 -m pip install android-env ``` -或者从 `main` 分支克隆仓库代码并通过 Bazel 进行编译与测试: +或者从 `main` 分支克隆仓库代码并通过 Bazel 进行编译与测试(请确保环境变量 `ANDROID_HOME` 指向本地安装的 Android SDK 目录): ```shell $ git clone https://github.com/google-deepmind/android_env/ @@ -105,11 +105,17 @@ with android_device_loader.load_android_device(config) as device: ### 3. 启动交互式 Web Playground -直接在浏览器中实时浏览设备屏幕、检查无障碍树节点并执行 ADB 命令: +直接在浏览器中实时浏览设备屏幕、检查无障碍树节点并执行 ADB 命令(通过 `pip install "android-env[playground]"` 安装后): ```shell -$ bazel run //android_env/playground:launch -- \ - --config=android_env/configs/emulator_config.py +$ android-env-playground --serial=emulator-5554 +``` + +或在源码仓库中通过 Bazel 启动本地 AVD: + +```shell +$ bazel run //android_env/playground:playground -- \ + --config=android_env/playground/configs/emulator_config.py ``` ### 4. 强化学习接入 (`dm_env`) diff --git a/android_env/BUILD.bazel b/android_env/BUILD.bazel index d76ed71d..4c94cf91 100644 --- a/android_env/BUILD.bazel +++ b/android_env/BUILD.bazel @@ -3,6 +3,35 @@ load("@rules_python//python:packaging.bzl", "py_package", "py_wheel") package(default_visibility = ["//visibility:public"]) +py_library( + name = "android_env", + srcs = ["__init__.py"], + deps = [ + ":env_interface", + ":loader", + "//android_env/components:android_device", + "//android_env/components:android_device_loader", + "//android_env/components:config_classes", + "//android_env/rl:environment", + ], +) + +py_test( + name = "init_test", + srcs = ["init_test.py"], + legacy_create_init = False, + deps = [ + ":android_env", + ":env_interface", + ":loader", + "//android_env/components:android_device", + "//android_env/components:android_device_loader", + "//android_env/components:config_classes", + "//android_env/rl:environment", + "@pip//absl_py", + ], +) + py_library( name = "env_interface", srcs = ["env_interface.py"], @@ -49,10 +78,13 @@ py_package( name = "android_env_pkg", packages = ["android_env"], deps = [ + ":android_env", ":loader", "//android_env/components:android_device_loader", + "//android_env/mcp:android_mcp_server_lib", "//android_env/mcp:android_mcp_tools", "//android_env/playground:playground_lib", + "//android_env/playground/configs:emulator_config", "//android_env/rl:environment", "//android_env/wrappers:discrete_action_wrapper", "//android_env/wrappers:flat_interface_wrapper", @@ -70,13 +102,23 @@ py_package( py_wheel( name = "android_env_wheel", author = "DeepMind", + description_content_type = "text/markdown", + description_file = "//:README.md", distribution = "android_env", + entry_points = { + "console_scripts": [ + "android-env-mcp = android_env.mcp.android_mcp_server:run", + "android-env-playground = android_env.playground.playground:run", + ], + }, extra_requires = { "gym": ["gymnasium>=0.29.1"], "mcp": ["mcp>=1.26,<2"], "playground": ["ml-collections"], }, + homepage = "https://github.com/google-deepmind/android_env", license = "Apache License, Version 2.0", + python_requires = ">=3.10", requires = [ "absl-py>=2.1.0", "dm-env>=1.6", @@ -87,7 +129,8 @@ py_wheel( "opencv-python-headless>=4.10.0", "websocket-client>=1.8.0", ], - summary = "AndroidEnv: A Gym/Gymnasium Environment for Android", + summary = "AndroidEnv: The Android Interaction & Learning Platform", + twine_binary = None, version = "2.0.0", deps = [":android_env_pkg"], ) diff --git a/android_env/__init__.py b/android_env/__init__.py index 0256ac69..24a0301f 100644 --- a/android_env/__init__.py +++ b/android_env/__init__.py @@ -13,3 +13,42 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""AndroidEnv: The Android Interaction & Learning Platform.""" + +from android_env import env_interface +from android_env import loader +from android_env.components import android_device +from android_env.components import android_device_loader +from android_env.components import config_classes +from android_env.rl import environment as rl_environment + +__version__ = '2.0.0' + +AdbControllerConfig = config_classes.AdbControllerConfig +AndroidDevice = android_device.AndroidDevice +AndroidDeviceConfig = config_classes.AndroidDeviceConfig +AndroidEnv = rl_environment.AndroidEnv +AndroidEnvConfig = config_classes.AndroidEnvConfig +AndroidEnvInterface = env_interface.AndroidEnvInterface +DeviceConnectionConfig = config_classes.DeviceConnectionConfig +EmulatorLauncherConfig = config_classes.EmulatorLauncherConfig +FilesystemTaskConfig = config_classes.FilesystemTaskConfig +TaskConfig = config_classes.TaskConfig +load = loader.load +load_android_device = android_device_loader.load_android_device + +__all__ = ( + '__version__', + 'AdbControllerConfig', + 'AndroidDevice', + 'AndroidDeviceConfig', + 'AndroidEnv', + 'AndroidEnvConfig', + 'AndroidEnvInterface', + 'DeviceConnectionConfig', + 'EmulatorLauncherConfig', + 'FilesystemTaskConfig', + 'TaskConfig', + 'load', + 'load_android_device', +) diff --git a/android_env/init_test.py b/android_env/init_test.py new file mode 100644 index 00000000..91f57dee --- /dev/null +++ b/android_env/init_test.py @@ -0,0 +1,70 @@ +# coding=utf-8 +# Copyright 2026 DeepMind Technologies Limited. +# +# 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. + +"""Tests for top-level android_env package exports.""" + +from absl.testing import absltest +import android_env +from android_env import env_interface +from android_env import loader +from android_env.components import android_device +from android_env.components import android_device_loader +from android_env.components import config_classes +from android_env.rl import environment as rl_environment + + +class InitTest(absltest.TestCase): + + def test_version(self): + self.assertEqual(android_env.__version__, '2.0.0') + + def test_public_exports(self): + self.assertIs( + android_env.AdbControllerConfig, config_classes.AdbControllerConfig + ) + self.assertIs(android_env.AndroidDevice, android_device.AndroidDevice) + self.assertIs( + android_env.AndroidDeviceConfig, config_classes.AndroidDeviceConfig + ) + self.assertIs(android_env.AndroidEnv, rl_environment.AndroidEnv) + self.assertIs(android_env.AndroidEnvConfig, config_classes.AndroidEnvConfig) + self.assertIs( + android_env.AndroidEnvInterface, env_interface.AndroidEnvInterface + ) + self.assertIs( + android_env.DeviceConnectionConfig, + config_classes.DeviceConnectionConfig, + ) + self.assertIs( + android_env.EmulatorLauncherConfig, + config_classes.EmulatorLauncherConfig, + ) + self.assertIs( + android_env.FilesystemTaskConfig, config_classes.FilesystemTaskConfig + ) + self.assertIs(android_env.TaskConfig, config_classes.TaskConfig) + self.assertIs(android_env.load, loader.load) + self.assertIs( + android_env.load_android_device, + android_device_loader.load_android_device, + ) + + def test_all_exports_present_on_module(self): + for symbol in android_env.__all__: + self.assertTrue(hasattr(android_env, symbol), msg=f'Missing {symbol}') + + +if __name__ == '__main__': + absltest.main() diff --git a/android_env/mcp/BUILD.bazel b/android_env/mcp/BUILD.bazel index 02d44d1d..69efc454 100644 --- a/android_env/mcp/BUILD.bazel +++ b/android_env/mcp/BUILD.bazel @@ -41,8 +41,8 @@ py_test( ], ) -py_binary( - name = "android_mcp_server", +py_library( + name = "android_mcp_server_lib", srcs = ["android_mcp_server.py"], deps = [ ":android_mcp_tools", @@ -52,3 +52,23 @@ py_binary( "@pip//mcp", ], ) + +py_binary( + name = "android_mcp_server", + srcs = ["android_mcp_server.py"], + deps = [ + ":android_mcp_server_lib", + ], +) + +py_test( + name = "android_mcp_server_test", + srcs = ["android_mcp_server_test.py"], + legacy_create_init = False, + deps = [ + ":android_mcp_server_lib", + "//android_env/components:android_device", + "//android_env/components:config_classes", + "@pip//absl_py", + ], +) diff --git a/android_env/mcp/android_mcp_server.py b/android_env/mcp/android_mcp_server.py index 4b96cf0a..503d4ab0 100644 --- a/android_env/mcp/android_mcp_server.py +++ b/android_env/mcp/android_mcp_server.py @@ -19,6 +19,7 @@ and interaction. Usage: + android-env-mcp --serial=emulator-5554 python -m android_env.mcp.android_mcp_server --serial=emulator-5554 """ @@ -60,5 +61,10 @@ def main(argv: Sequence[str]) -> None: mcp_server.run(transport='stdio') -if __name__ == '__main__': +def run() -> None: + """Console-script entry point for `android-env-mcp`.""" app.run(main) + + +if __name__ == '__main__': + run() diff --git a/android_env/mcp/android_mcp_server_test.py b/android_env/mcp/android_mcp_server_test.py new file mode 100644 index 00000000..8418424a --- /dev/null +++ b/android_env/mcp/android_mcp_server_test.py @@ -0,0 +1,82 @@ +# coding=utf-8 +# Copyright 2026 DeepMind Technologies Limited. +# +# 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. + +"""Unit tests for the FastMCP server entry point in android_mcp_server.""" + +from unittest import mock + +from absl import app +from absl.testing import absltest +from absl.testing import flagsaver +from android_env.components import android_device +from android_env.components import config_classes +from android_env.mcp import android_mcp_server + + +class AndroidMcpServerTest(absltest.TestCase): + + def test_main_raises_usage_error_on_extra_args(self): + with self.assertRaises(app.UsageError): + android_mcp_server.main(['android_mcp_server', 'extra_arg']) + + @mock.patch.object( + android_mcp_server.android_mcp_tools, 'AndroidMcpTools', autospec=True + ) + @mock.patch.object(android_mcp_server.fastmcp, 'FastMCP', autospec=True) + @mock.patch.object( + android_mcp_server.android_device_loader, + 'load_android_device', + autospec=True, + ) + def test_main_loads_device_and_runs_stdio_server( + self, mock_load_device, mock_fastmcp_cls, mock_tools_cls + ): + mock_device = mock.create_autospec( + android_device.AndroidDevice, instance=True + ) + mock_load_device.return_value = mock_device + + with flagsaver.flagsaver( + (android_mcp_server._SERIAL, 'emulator-5554'), + (android_mcp_server._HOST, '127.0.0.1'), + (android_mcp_server._WEBSOCKET_PORT, 8001), + ): + android_mcp_server.main(['android_mcp_server']) + + mock_load_device.assert_called_once_with( + config_classes.AndroidDeviceConfig( + serial='emulator-5554', + auto_setup=True, + device_connection=config_classes.DeviceConnectionConfig( + host='127.0.0.1', + websocket_port=8001, + ), + ) + ) + mock_fastmcp_cls.assert_called_once_with(name='android_mcp_server') + mock_tools_cls.assert_called_once_with(mock_device) + mock_tools_cls.return_value.register_tools.assert_called_once_with( + mock_fastmcp_cls.return_value + ) + mock_fastmcp_cls.return_value.run.assert_called_once_with(transport='stdio') + + @mock.patch.object(android_mcp_server.app, 'run', autospec=True) + def test_run_invokes_app_run(self, mock_app_run): + android_mcp_server.run() + mock_app_run.assert_called_once_with(android_mcp_server.main) + + +if __name__ == '__main__': + absltest.main() diff --git a/android_env/playground/BUILD.bazel b/android_env/playground/BUILD.bazel index 4642c29f..24ea3bdf 100644 --- a/android_env/playground/BUILD.bazel +++ b/android_env/playground/BUILD.bazel @@ -98,6 +98,8 @@ py_library( ":server", ":state", ":websocket", + "//android_env/components:config_classes", + "//android_env/playground/configs:emulator_config", "@pip//absl_py", "@pip//ml_collections", ], @@ -216,6 +218,7 @@ py_test( ":playground_lib", ":state", "//android_env/components:android_device", + "//android_env/components:config_classes", "@pip//absl_py", "@pip//numpy", ], diff --git a/android_env/playground/README.md b/android_env/playground/README.md index 81aa7a74..b32ff90d 100644 --- a/android_env/playground/README.md +++ b/android_env/playground/README.md @@ -37,7 +37,17 @@ bazel run //android_env/playground:playground -- \ ### Option 2: Running with Python -After installing AndroidEnv from source or via pip: +After installing `android-env[playground]` via pip: + +```bash +# Connect to an already-running emulator or physical device: +android-env-playground --serial=emulator-5554 --port=8080 + +# Or launch a local AVD directly by name: +android-env-playground --avd_name=androidworld_avd --port=8080 +``` + +Or run the module with a custom config file from a source checkout: ```bash python -m android_env.playground.playground \ diff --git a/android_env/playground/frontend/BUILD.bazel b/android_env/playground/frontend/BUILD.bazel index 93bbdcb5..df45d434 100644 --- a/android_env/playground/frontend/BUILD.bazel +++ b/android_env/playground/frontend/BUILD.bazel @@ -1,3 +1,4 @@ +load("@aspect_rules_esbuild//esbuild:defs.bzl", "esbuild") load("@aspect_rules_ts//ts:defs.bzl", "ts_project") load("@npm//:defs.bzl", "npm_link_all_packages") @@ -24,11 +25,24 @@ ts_project( deps = [":node_modules/safevalues"], ) +esbuild( + name = "main_bin", + srcs = [ + ":frontend_ts", + ":node_modules/safevalues", + ], + entry_point = "main.js", + format = "iife", + minify = True, + output = "main_bin.js", + target = "es2022", +) + filegroup( name = "frontend", srcs = [ "index.html", - ":frontend_ts", + ":main_bin.js", ], visibility = ["//visibility:public"], ) diff --git a/android_env/playground/playground.py b/android_env/playground/playground.py index 4e3d2350..12b3d928 100644 --- a/android_env/playground/playground.py +++ b/android_env/playground/playground.py @@ -22,14 +22,28 @@ from absl import app from absl import flags from absl import logging +from android_env.components import config_classes from android_env.playground import device_runner from android_env.playground import log_handler from android_env.playground import server from android_env.playground import state as state_lib from android_env.playground import websocket +from android_env.playground.configs import emulator_config from ml_collections.config_flags import config_flags _PORT = flags.DEFINE_integer('port', 8080, 'Port to serve the web interface.') +_SERIAL = flags.DEFINE_string( + 'serial', + None, + 'ADB serial of an already-running emulator or physical device (used when' + ' --config is not set).', +) +_AVD_NAME = flags.DEFINE_string( + 'avd_name', + None, + 'Local Android Virtual Device (AVD) name to launch (used when --config is' + ' not set).', +) _CONFIG = config_flags.DEFINE_config_file( 'config', None, @@ -38,6 +52,19 @@ ) +def _resolve_config(): + """Resolves the Playground configuration from command-line flags.""" + if _CONFIG.value is not None: + return _CONFIG.value + if _SERIAL.value and _AVD_NAME.value: + raise app.UsageError('Specify at most one of --serial or --avd_name.') + if _SERIAL.value: + return config_classes.AndroidDeviceConfig(serial=_SERIAL.value) + if _AVD_NAME.value: + return emulator_config.get_config(_AVD_NAME.value) + return None + + def _run_device_thread(state: state_lib.PlaygroundState, config): """Orchestrates device setup, running, and cleanup in background.""" try: @@ -92,6 +119,7 @@ def _serve_http(state: state_lib.PlaygroundState, static_dir: str, port: int): def main(_): + config = _resolve_config() static_dir = server.get_default_static_dir() state = state_lib.PlaygroundState() @@ -114,7 +142,7 @@ def main(_): target=_run_device_thread, args=( state, - _CONFIG.value, + config, ), daemon=True, ) @@ -135,5 +163,10 @@ def main(_): custom_log_handler.close() -if __name__ == '__main__': +def run() -> None: + """Console-script entry point for `android-env-playground`.""" app.run(main) + + +if __name__ == '__main__': + run() diff --git a/android_env/playground/playground_test.py b/android_env/playground/playground_test.py index 7a282632..7bc5bb7c 100644 --- a/android_env/playground/playground_test.py +++ b/android_env/playground/playground_test.py @@ -20,7 +20,9 @@ from unittest import mock from absl.testing import absltest +from absl.testing import flagsaver from android_env.components import android_device +from android_env.components import config_classes from android_env.playground import playground from android_env.playground import state as state_lib import numpy as np @@ -135,6 +137,58 @@ def test_main_starts_thread_and_serves_http( mock_serve_http.assert_called_once_with(mock.ANY, '/fake/static', 8080) mock_thread_cls.return_value.join.assert_called_once_with(timeout=5.0) + @mock.patch.object(playground, '_serve_http', autospec=True) + @mock.patch.object( + playground.server, + 'get_default_static_dir', + autospec=True, + return_value='/fake/static', + ) + @mock.patch.object(playground.threading, 'Thread', autospec=True) + def test_main_uses_serial_flag_when_config_unset( + self, mock_thread_cls, unused_mock_static_dir, unused_mock_serve_http + ): + with flagsaver.flagsaver((playground._SERIAL, 'emulator-5554')): + playground.main(['playground']) + + passed_config = mock_thread_cls.call_args.kwargs['args'][1] + self.assertEqual( + passed_config, + config_classes.AndroidDeviceConfig(serial='emulator-5554'), + ) + + @mock.patch.object(playground, '_serve_http', autospec=True) + @mock.patch.object( + playground.server, + 'get_default_static_dir', + autospec=True, + return_value='/fake/static', + ) + @mock.patch.object(playground.threading, 'Thread', autospec=True) + def test_main_uses_avd_name_flag_when_config_unset( + self, mock_thread_cls, unused_mock_static_dir, unused_mock_serve_http + ): + with flagsaver.flagsaver((playground._AVD_NAME, 'my_test_avd')): + playground.main(['playground']) + + passed_config = mock_thread_cls.call_args.kwargs['args'][1] + self.assertIsInstance(passed_config, config_classes.AndroidDeviceConfig) + self.assertIsNotNone(passed_config.emulator_launcher) + self.assertEqual(passed_config.emulator_launcher.avd_name, 'my_test_avd') + + def test_main_raises_usage_error_when_serial_and_avd_name_both_set(self): + with flagsaver.flagsaver( + (playground._SERIAL, 'emulator-5554'), + (playground._AVD_NAME, 'my_test_avd'), + ): + with self.assertRaises(playground.app.UsageError): + playground.main(['playground']) + + @mock.patch.object(playground.app, 'run', autospec=True) + def test_run_invokes_app_run(self, mock_app_run): + playground.run() + mock_app_run.assert_called_once_with(playground.main) + def test_main_execution(self): original_setitem = playground.flags.FlagValues.__setitem__ diff --git a/android_env/playground/server.py b/android_env/playground/server.py index 6748a9c6..f8044daf 100644 --- a/android_env/playground/server.py +++ b/android_env/playground/server.py @@ -42,6 +42,7 @@ def get_default_static_dir() -> str: runfiles_dir = os.environ.get(env_var) if runfiles_dir: for rel_path in ( + '_main/android_env/playground/frontend', 'android_env/playground/frontend', ): candidate = os.path.join(runfiles_dir, rel_path) diff --git a/android_env/playground/server_test.py b/android_env/playground/server_test.py index 55d1077d..598caddc 100644 --- a/android_env/playground/server_test.py +++ b/android_env/playground/server_test.py @@ -131,6 +131,8 @@ def test_do_get_invalid_server_type(self, unused_mock_super_init): def test_get_default_static_dir_returns_existing_dir(self): static_dir = server.get_default_static_dir() self.assertIsNotNone(static_dir) + self.assertTrue(os.path.exists(os.path.join(static_dir, 'index.html'))) + self.assertTrue(os.path.exists(os.path.join(static_dir, 'main_bin.js'))) @mock.patch.object(os.path, 'exists', autospec=True) def test_get_default_static_dir_resource_path(self, mock_exists): @@ -164,6 +166,28 @@ def side_effect(path): static_dir = server.get_default_static_dir() self.assertEqual(static_dir, expected_dir) + @mock.patch.object(os.path, 'exists', autospec=True) + def test_get_default_static_dir_fallback_to_bzlmod_runfiles_dir( + self, mock_exists + ): + def side_effect(path): + return ( + path + == '/fake/runfiles/_main/android_env/playground/frontend/index.html' + ) + + mock_exists.side_effect = side_effect + with ( + mock.patch.object(server, '_get_resource_filename', return_value=''), + mock.patch.dict( + os.environ, {'RUNFILES_DIR': '/fake/runfiles'}, clear=True + ), + ): + static_dir = server.get_default_static_dir() + self.assertEqual( + static_dir, '/fake/runfiles/_main/android_env/playground/frontend' + ) + @mock.patch.object(os.path, 'exists', autospec=True) def test_get_default_static_dir_fallback_to_runfiles_dir(self, mock_exists): def side_effect(path): diff --git a/pyproject.toml b/pyproject.toml index 49a396ae..8db73f3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,14 @@ gym = ["gymnasium>=0.29.1"] mcp = ["mcp>=1.26,<2"] playground = ["ml-collections"] +[project.scripts] +android-env-mcp = "android_env.mcp.android_mcp_server:run" +android-env-playground = "android_env.playground.playground:run" + [tool.setuptools.package-data] "android_env.apps.java.com.google.androidenv.core" = ["*.apk"] "android_env.components" = ["*.textproto"] +"android_env.playground.frontend" = ["index.html", "*.js"] [project.urls] repository = "https://github.com/google-deepmind/android_env"