541 lines
19 KiB
Python
541 lines
19 KiB
Python
import dataclasses
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
import ray
|
|
import ray._private.ray_constants as ray_constants
|
|
from ray._private.runtime_env.uri_cache import URICache
|
|
from ray._private.runtime_env.utils import (
|
|
SubprocessCalledProcessError,
|
|
check_output_cmd,
|
|
)
|
|
from ray._private.test_utils import (
|
|
chdir,
|
|
)
|
|
from ray.runtime_env import RuntimeEnv
|
|
from ray.runtime_env.runtime_env import (
|
|
RuntimeEnvConfig,
|
|
_merge_runtime_env,
|
|
)
|
|
|
|
|
|
def test_runtime_env_merge():
|
|
# Both are None.
|
|
parent = None
|
|
child = None
|
|
assert _merge_runtime_env(parent, child) == {}
|
|
|
|
parent = {}
|
|
child = None
|
|
assert _merge_runtime_env(parent, child) == {}
|
|
|
|
parent = None
|
|
child = {}
|
|
assert _merge_runtime_env(parent, child) == {}
|
|
|
|
parent = {}
|
|
child = {}
|
|
assert _merge_runtime_env(parent, child) == {}
|
|
|
|
# Only parent is given.
|
|
parent = {"conda": ["requests"], "env_vars": {"A": "1"}}
|
|
child = None
|
|
assert _merge_runtime_env(parent, child) == parent
|
|
|
|
# Only child is given.
|
|
parent = None
|
|
child = {"conda": ["requests"], "env_vars": {"A": "1"}}
|
|
assert _merge_runtime_env(parent, child) == child
|
|
|
|
# Successful case.
|
|
parent = {"conda": ["requests"], "env_vars": {"A": "1"}}
|
|
child = {"pip": ["requests"], "env_vars": {"B": "2"}}
|
|
assert _merge_runtime_env(parent, child) == {
|
|
"conda": ["requests"],
|
|
"pip": ["requests"],
|
|
"env_vars": {"A": "1", "B": "2"},
|
|
}
|
|
|
|
# Failure case
|
|
parent = {"pip": ["requests"], "env_vars": {"A": "1"}}
|
|
child = {"pip": ["colors"], "env_vars": {"B": "2"}}
|
|
assert _merge_runtime_env(parent, child) is None
|
|
|
|
# Failure case (env_vars)
|
|
parent = {"pip": ["requests"], "env_vars": {"A": "1"}}
|
|
child = {"conda": ["requests"], "env_vars": {"A": "2"}}
|
|
assert _merge_runtime_env(parent, child) is None
|
|
|
|
# override = True
|
|
parent = {"pip": ["requests"], "env_vars": {"A": "1"}}
|
|
child = {"pip": ["colors"], "env_vars": {"B": "2"}}
|
|
assert _merge_runtime_env(parent, child, override=True) == {
|
|
"pip": ["colors"],
|
|
"env_vars": {"A": "1", "B": "2"},
|
|
}
|
|
|
|
# override = True + env vars
|
|
parent = {"pip": ["requests"], "env_vars": {"A": "1"}}
|
|
child = {"pip": ["colors"], "conda": ["requests"], "env_vars": {"A": "2"}}
|
|
assert _merge_runtime_env(parent, child, override=True) == {
|
|
"pip": ["colors"],
|
|
"env_vars": {"A": "2"},
|
|
"conda": ["requests"],
|
|
}
|
|
|
|
|
|
def test_current_py_version_supported():
|
|
"""Test that the running python version is supported.
|
|
|
|
This is run as a check in the Ray `runtime_env` `conda` code
|
|
before downloading the Ray wheel into the conda environment.
|
|
If Ray wheels are not available for this python version, then
|
|
the `conda` environment installation will fail.
|
|
|
|
When a new python version is added to the Ray wheels, please update
|
|
`ray_constants.RUNTIME_ENV_CONDA_PY_VERSIONS`. In a subsequent commit,
|
|
once wheels have been built for the new python version, please update
|
|
the tests test_get_wheel_filename, test_get_master_wheel_url, and
|
|
(after the first Ray release with the new python version)
|
|
test_get_release_wheel_url.
|
|
"""
|
|
py_version = sys.version_info[:2]
|
|
assert py_version in ray_constants.RUNTIME_ENV_CONDA_PY_VERSIONS
|
|
|
|
|
|
def test_compatible_with_dataclasses():
|
|
"""Test that the output of RuntimeEnv.to_dict() can be used as a dataclass field."""
|
|
config = RuntimeEnvConfig(setup_timeout_seconds=1)
|
|
runtime_env = RuntimeEnv(
|
|
pip={
|
|
"packages": ["tensorflow", "requests"],
|
|
"pip_check": False,
|
|
"pip_version": "==23.3.2;python_version=='3.9.16'",
|
|
},
|
|
env_vars={"FOO": "BAR"},
|
|
config=config,
|
|
)
|
|
|
|
@dataclass
|
|
class RuntimeEnvDataClass:
|
|
runtime_env: Dict[str, Any]
|
|
|
|
dataclasses.asdict(RuntimeEnvDataClass(runtime_env.to_dict()))
|
|
|
|
@dataclass
|
|
class RuntimeEnvConfigDataClass:
|
|
config: Dict[str, Any]
|
|
|
|
dataclasses.asdict(RuntimeEnvConfigDataClass(config.to_dict()))
|
|
|
|
|
|
@pytest.mark.parametrize("runtime_env_class", [dict, RuntimeEnv])
|
|
def test_container_option_serialize(runtime_env_class):
|
|
runtime_env = runtime_env_class(
|
|
container={"image": "ray:latest", "run_options": ["--name=test"]}
|
|
)
|
|
job_config = ray.job_config.JobConfig(runtime_env=runtime_env)
|
|
job_config_serialized = job_config._serialize()
|
|
# job_config_serialized is JobConfig protobuf serialized string,
|
|
# job_config.runtime_env_info.serialized_runtime_env
|
|
# has container_option info
|
|
assert job_config_serialized.count(b"ray:latest") == 1
|
|
assert job_config_serialized.count(b"--name=test") == 1
|
|
|
|
|
|
class TestURICache:
|
|
def test_zero_cache_size(self):
|
|
uris_to_sizes = {"5": 5, "3": 3}
|
|
|
|
def delete_fn(uri, logger):
|
|
return uris_to_sizes[uri]
|
|
|
|
cache = URICache(delete_fn, max_total_size_bytes=0, debug_mode=True)
|
|
cache.add("5", 5)
|
|
assert cache.get_total_size_bytes() == 5
|
|
cache.mark_unused("5")
|
|
assert cache.get_total_size_bytes() == 0
|
|
cache.add("3", 3)
|
|
cache.add("5", 5)
|
|
assert cache.get_total_size_bytes() == 8
|
|
cache.mark_unused("3")
|
|
cache.mark_unused("5")
|
|
assert cache.get_total_size_bytes() == 0
|
|
|
|
def test_nonzero_cache_size(self):
|
|
uris_to_sizes = {"a": 4, "b": 4, "c": 4}
|
|
|
|
def delete_fn(uri, logger):
|
|
return uris_to_sizes[uri]
|
|
|
|
cache = URICache(delete_fn, max_total_size_bytes=10, debug_mode=True)
|
|
cache.add("a", 4)
|
|
cache.add("b", 4)
|
|
cache.mark_unused("a")
|
|
assert "a" in cache
|
|
cache.add("c", 4)
|
|
# Now we have total size 12, which exceeds the max size 10.
|
|
assert cache.get_total_size_bytes() == 8
|
|
# "a" was the only unused URI, so it must have been deleted.
|
|
assert "b" and "c" in cache and "a" not in cache
|
|
|
|
def test_mark_used_nonadded_uri_error(self):
|
|
cache = URICache(debug_mode=True)
|
|
with pytest.raises(ValueError):
|
|
cache.mark_used("nonadded_uri")
|
|
|
|
def test_mark_used(self):
|
|
uris_to_sizes = {"a": 3, "b": 3, "big": 300}
|
|
|
|
def delete_fn(uri, logger):
|
|
return uris_to_sizes[uri]
|
|
|
|
cache = URICache(delete_fn, max_total_size_bytes=10, debug_mode=True)
|
|
cache.add("a", 3)
|
|
cache.add("b", 3)
|
|
cache.mark_unused("a")
|
|
cache.mark_unused("b")
|
|
assert "a" in cache and "b" in cache
|
|
assert cache.get_total_size_bytes() == 6
|
|
|
|
cache.mark_used("a")
|
|
cache.add("big", 300)
|
|
# We are over capacity and the only unused URI is "b", so we delete it
|
|
assert "a" in cache and "big" in cache and "b" not in cache
|
|
assert cache.get_total_size_bytes() == 303
|
|
|
|
cache.mark_unused("big")
|
|
assert "big" not in cache
|
|
assert cache.get_total_size_bytes() == 3
|
|
|
|
def test_many_URIs(self):
|
|
uris_to_sizes = {str(i): i for i in range(1000)}
|
|
|
|
def delete_fn(uri, logger):
|
|
return uris_to_sizes[uri]
|
|
|
|
cache = URICache(delete_fn, debug_mode=True)
|
|
for i in range(1000):
|
|
cache.add(str(i), i)
|
|
for i in range(1000):
|
|
cache.mark_unused(str(i))
|
|
for i in range(1000):
|
|
assert str(i) in cache
|
|
|
|
def test_delete_fn_called(self):
|
|
num_delete_fn_calls = 0
|
|
uris_to_sizes = {"a": 8, "b": 6, "c": 4, "d": 20}
|
|
|
|
def delete_fn(uri, logger):
|
|
nonlocal num_delete_fn_calls
|
|
num_delete_fn_calls += 1
|
|
return uris_to_sizes[uri]
|
|
|
|
cache = URICache(delete_fn, max_total_size_bytes=10, debug_mode=True)
|
|
cache.add("a", 8)
|
|
cache.add("b", 6)
|
|
cache.mark_unused("b")
|
|
# Total size is 14 > 10, so we need to delete "b".
|
|
assert num_delete_fn_calls == 1
|
|
|
|
cache.add("c", 4)
|
|
cache.mark_unused("c")
|
|
# Total size is 12 > 10, so we delete "c".
|
|
assert num_delete_fn_calls == 2
|
|
|
|
cache.mark_unused("a")
|
|
# Total size is 8 <= 10, so we shouldn't delete anything.
|
|
assert num_delete_fn_calls == 2
|
|
|
|
cache.add("d", 20)
|
|
# Total size is 28 > 10, so we delete "a".
|
|
assert num_delete_fn_calls == 3
|
|
|
|
cache.mark_unused("d")
|
|
# Total size is 20 > 10, so we delete "d".
|
|
assert num_delete_fn_calls == 4
|
|
|
|
|
|
@pytest.fixture
|
|
def enable_dev_mode(local_env_var_enabled, monkeypatch):
|
|
enabled = "1" if local_env_var_enabled else "0"
|
|
monkeypatch.setenv("RAY_RUNTIME_ENV_LOG_TO_DRIVER_ENABLED", enabled)
|
|
yield
|
|
|
|
|
|
def test_subprocess_error():
|
|
ex = SubprocessCalledProcessError
|
|
with pytest.raises(subprocess.SubprocessError) as e:
|
|
raise ex(123, "abc")
|
|
assert "test_out" not in str(e.value)
|
|
assert "test_err" not in str(e.value)
|
|
with pytest.raises(subprocess.SubprocessError) as e:
|
|
raise ex(123, "abc", stderr="test_err")
|
|
assert "test_out" not in str(e.value)
|
|
assert "test_err" in str(e.value)
|
|
with pytest.raises(subprocess.SubprocessError) as e:
|
|
raise ex(123, "abc", output="test_out")
|
|
assert "test_out" in str(e.value)
|
|
assert "test_err" not in str(e.value)
|
|
with pytest.raises(subprocess.SubprocessError) as e:
|
|
raise ex(123, "abc", output="test_out", stderr="test_err")
|
|
assert "test_out" in str(e.value)
|
|
assert "test_err" in str(e.value)
|
|
|
|
|
|
def test_subprocess_error_with_last_n_lines():
|
|
stdout = "1\n2\n3\n4\n5\n"
|
|
stderr = "5\n4\n3\n2\n1\n"
|
|
exception = SubprocessCalledProcessError(888, "abc", output=stdout, stderr=stderr)
|
|
exception.LAST_N_LINES = 3
|
|
exception_str = str(exception)
|
|
assert "cmd" not in exception_str
|
|
assert "Last 3 lines" in exception_str
|
|
s = "".join([s.strip() for s in exception_str.splitlines()])
|
|
assert "345" in s
|
|
assert "321" in s
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_output_cmd():
|
|
cmd = "dir" if sys.platform.startswith("win") else "pwd"
|
|
logs = []
|
|
|
|
class _FakeLogger:
|
|
def __getattr__(self, item):
|
|
def _log(formatter, *args):
|
|
logs.append(formatter % args)
|
|
|
|
return _log
|
|
|
|
for _ in range(2):
|
|
output = await check_output_cmd([cmd], logger=_FakeLogger())
|
|
assert len(output) > 0
|
|
|
|
all_log_string = "\n".join(logs)
|
|
|
|
# Check the cmd index generator works.
|
|
assert "cmd[1]" in all_log_string
|
|
assert "cmd[2]" in all_log_string
|
|
|
|
# Test communicate fails.
|
|
with mock.patch(
|
|
"asyncio.subprocess.Process.communicate",
|
|
side_effect=Exception("fake exception"),
|
|
):
|
|
with pytest.raises(RuntimeError) as e:
|
|
await check_output_cmd([cmd], logger=_FakeLogger())
|
|
# Make sure the exception has cmd trace info.
|
|
assert "cmd[3]" in str(e.value)
|
|
|
|
# Test asyncio.create_subprocess_exec fails.
|
|
with pytest.raises(RuntimeError) as e:
|
|
await check_output_cmd(["not_exist_cmd"], logger=_FakeLogger())
|
|
# Make sure the exception has cmd trace info.
|
|
assert "cmd[4]" in str(e.value)
|
|
|
|
# Test returncode != 0.
|
|
with pytest.raises(SubprocessCalledProcessError) as e:
|
|
await check_output_cmd([cmd, "--abc"], logger=_FakeLogger())
|
|
# Make sure the exception has cmd trace info.
|
|
assert "cmd[5]" in str(e.value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"option",
|
|
["pip_list", "pip_dict", "conda_name", "conda_dict", "container"],
|
|
)
|
|
def test_serialize_deserialize(option):
|
|
runtime_env = dict()
|
|
if option == "pip_list":
|
|
runtime_env["pip"] = ["pkg1", "pkg2"]
|
|
elif option == "pip_dict":
|
|
runtime_env["pip"] = {
|
|
"packages": ["pkg1", "pkg2"],
|
|
"pip_check": False,
|
|
"pip_version": "<22,>20",
|
|
}
|
|
elif option == "conda_name":
|
|
runtime_env["conda"] = "env_name"
|
|
elif option == "conda_dict":
|
|
runtime_env["conda"] = {"dependencies": ["dep1", "dep2"]}
|
|
elif option == "container":
|
|
runtime_env["container"] = {
|
|
"image": "anyscale/ray-ml:nightly-py38-cpu",
|
|
"worker_path": "/root/python/ray/_private/workers/default_worker.py",
|
|
"run_options": ["--cap-drop SYS_ADMIN", "--log-level=debug"],
|
|
}
|
|
else:
|
|
raise ValueError("unexpected option " + str(option))
|
|
|
|
typed_runtime_env = RuntimeEnv(**runtime_env)
|
|
serialized_runtime_env = typed_runtime_env.serialize()
|
|
cls_runtime_env = RuntimeEnv.deserialize(serialized_runtime_env)
|
|
cls_runtime_env_dict = cls_runtime_env.to_dict()
|
|
|
|
if "pip" in typed_runtime_env and isinstance(typed_runtime_env["pip"], list):
|
|
pip_config_in_cls_runtime_env = cls_runtime_env_dict.pop("pip")
|
|
pip_config_in_runtime_env = typed_runtime_env.pop("pip")
|
|
assert {
|
|
"packages": pip_config_in_runtime_env,
|
|
"pip_check": False,
|
|
} == pip_config_in_cls_runtime_env
|
|
|
|
assert cls_runtime_env_dict == typed_runtime_env
|
|
|
|
|
|
def test_runtime_env_interface():
|
|
# Test the interface related to working_dir
|
|
default_working_dir = "s3://bucket/key.zip"
|
|
modify_working_dir = "s3://bucket/key_A.zip"
|
|
runtime_env = RuntimeEnv(working_dir=default_working_dir)
|
|
runtime_env_dict = runtime_env.to_dict()
|
|
assert runtime_env.working_dir_uri() == default_working_dir
|
|
runtime_env["working_dir"] = modify_working_dir
|
|
runtime_env_dict["working_dir"] = modify_working_dir
|
|
assert runtime_env.working_dir_uri() == modify_working_dir
|
|
assert runtime_env.to_dict() == runtime_env_dict
|
|
|
|
runtime_env.pop("working_dir")
|
|
assert runtime_env.to_dict() == {}
|
|
|
|
# Test the interface related to py_modules
|
|
init_py_modules = ["s3://bucket/key_1.zip", "s3://bucket/key_2.zip"]
|
|
addition_py_modules = ["s3://bucket/key_3.zip", "s3://bucket/key_4.zip"]
|
|
runtime_env = RuntimeEnv(py_modules=init_py_modules)
|
|
runtime_env_dict = runtime_env.to_dict()
|
|
assert set(runtime_env.py_modules_uris()) == set(init_py_modules)
|
|
runtime_env["py_modules"].extend(addition_py_modules)
|
|
runtime_env_dict["py_modules"].extend(addition_py_modules)
|
|
assert set(runtime_env.py_modules_uris()) == set(
|
|
init_py_modules + addition_py_modules
|
|
)
|
|
assert runtime_env.to_dict() == runtime_env_dict
|
|
|
|
runtime_env.pop("py_modules")
|
|
assert runtime_env.to_dict() == {}
|
|
|
|
# Test the interface related to env_vars
|
|
init_env_vars = {"A": "a", "B": "b"}
|
|
update_env_vars = {"C": "c"}
|
|
runtime_env = RuntimeEnv(env_vars=init_env_vars)
|
|
runtime_env_dict = runtime_env.to_dict()
|
|
runtime_env["env_vars"].update(update_env_vars)
|
|
runtime_env_dict["env_vars"].update(update_env_vars)
|
|
init_env_vars_copy = init_env_vars.copy()
|
|
init_env_vars_copy.update(update_env_vars)
|
|
assert runtime_env["env_vars"] == init_env_vars_copy
|
|
assert runtime_env_dict == runtime_env.to_dict()
|
|
|
|
runtime_env.pop("env_vars")
|
|
assert runtime_env.to_dict() == {}
|
|
|
|
# Test the interface related to conda
|
|
conda_name = "conda"
|
|
modify_conda_name = "conda_A"
|
|
conda_config = {"dependencies": ["dep1", "dep2"]}
|
|
runtime_env = RuntimeEnv(conda=conda_name)
|
|
runtime_env_dict = runtime_env.to_dict()
|
|
assert runtime_env.has_conda()
|
|
assert runtime_env.conda_env_name() == conda_name
|
|
assert runtime_env.conda_config() is None
|
|
runtime_env["conda"] = modify_conda_name
|
|
runtime_env_dict["conda"] = modify_conda_name
|
|
assert runtime_env_dict == runtime_env.to_dict()
|
|
assert runtime_env.has_conda()
|
|
assert runtime_env.conda_env_name() == modify_conda_name
|
|
assert runtime_env.conda_config() is None
|
|
runtime_env["conda"] = conda_config
|
|
runtime_env_dict["conda"] = conda_config
|
|
assert runtime_env_dict == runtime_env.to_dict()
|
|
assert runtime_env.has_conda()
|
|
assert runtime_env.conda_env_name() is None
|
|
assert runtime_env.conda_config() == json.dumps(conda_config, sort_keys=True)
|
|
|
|
runtime_env.pop("conda")
|
|
assert runtime_env.to_dict() == {"_ray_commit": "{{RAY_COMMIT_SHA}}"}
|
|
|
|
# Test the interface related to pip
|
|
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
|
|
requirement_file = os.path.join(tmpdir, "requirements.txt")
|
|
requirement_packages = ["dep5", "dep6"]
|
|
with open(requirement_file, "wt") as f:
|
|
for package in requirement_packages:
|
|
f.write(package)
|
|
f.write("\n")
|
|
|
|
pip_packages = ["dep1", "dep2"]
|
|
addition_pip_packages = ["dep3", "dep4"]
|
|
runtime_env = RuntimeEnv(pip=pip_packages)
|
|
runtime_env_dict = runtime_env.to_dict()
|
|
assert runtime_env.has_pip()
|
|
assert set(runtime_env.pip_config()["packages"]) == set(pip_packages)
|
|
assert runtime_env.virtualenv_name() is None
|
|
runtime_env["pip"]["packages"].extend(addition_pip_packages)
|
|
runtime_env_dict["pip"]["packages"].extend(addition_pip_packages)
|
|
# The default value of pip_check is False
|
|
runtime_env_dict["pip"]["pip_check"] = False
|
|
assert runtime_env_dict == runtime_env.to_dict()
|
|
assert runtime_env.has_pip()
|
|
assert set(runtime_env.pip_config()["packages"]) == set(
|
|
pip_packages + addition_pip_packages
|
|
)
|
|
assert runtime_env.virtualenv_name() is None
|
|
runtime_env["pip"] = requirement_file
|
|
runtime_env_dict["pip"] = requirement_packages
|
|
assert runtime_env.has_pip()
|
|
assert set(runtime_env.pip_config()["packages"]) == set(requirement_packages)
|
|
assert runtime_env.virtualenv_name() is None
|
|
# The default value of pip_check is False
|
|
runtime_env_dict["pip"] = dict(
|
|
packages=runtime_env_dict["pip"], pip_check=False
|
|
)
|
|
assert runtime_env_dict == runtime_env.to_dict()
|
|
|
|
runtime_env.pop("pip")
|
|
assert runtime_env.to_dict() == {"_ray_commit": "{{RAY_COMMIT_SHA}}"}
|
|
|
|
# Test conflict
|
|
with pytest.raises(ValueError):
|
|
RuntimeEnv(pip=pip_packages, conda=conda_name)
|
|
|
|
runtime_env = RuntimeEnv(pip=pip_packages)
|
|
runtime_env["conda"] = conda_name
|
|
with pytest.raises(ValueError):
|
|
runtime_env.serialize()
|
|
|
|
# Test the interface related to container
|
|
container_init = {
|
|
"image": "anyscale/ray-ml:nightly-py38-cpu",
|
|
"run_options": ["--cap-drop SYS_ADMIN", "--log-level=debug"],
|
|
}
|
|
update_container = {"image": "test_modify"}
|
|
runtime_env = RuntimeEnv(container=container_init)
|
|
runtime_env_dict = runtime_env.to_dict()
|
|
assert runtime_env.has_py_container()
|
|
assert runtime_env.py_container_image() == container_init["image"]
|
|
assert runtime_env.py_container_run_options() == container_init["run_options"]
|
|
runtime_env["container"].update(update_container)
|
|
runtime_env_dict["container"].update(update_container)
|
|
container_copy = container_init
|
|
container_copy.update(update_container)
|
|
assert runtime_env_dict == runtime_env.to_dict()
|
|
assert runtime_env.has_py_container()
|
|
assert runtime_env.py_container_image() == container_copy["image"]
|
|
assert runtime_env.py_container_run_options() == container_copy["run_options"]
|
|
|
|
runtime_env.pop("container")
|
|
assert runtime_env.to_dict() == {}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-sv", __file__]))
|