Files
ray-project--ray/python/ray/tests/unit/test_runtime_env.py
T
2026-07-13 13:17:40 +08:00

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__]))