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

1144 lines
36 KiB
Python

import os
import platform
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import List
from unittest import mock
import pytest
import yaml
import ray
from ray._common.test_utils import (
run_string_as_driver,
wait_for_condition,
)
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env.conda import (
_current_py_version,
_inject_ray_to_conda_site,
_resolve_install_from_source_ray_dependencies,
inject_dependencies,
)
from ray._private.runtime_env.conda_utils import (
get_conda_env_list,
get_conda_envs,
get_conda_info_json,
)
from ray._private.test_utils import (
chdir,
run_string_as_driver_nonblocking,
)
from ray._private.utils import (
get_conda_bin_executable,
get_conda_env_dir,
)
from ray.runtime_env import RuntimeEnv
if not os.environ.get("CI"):
# This flags turns on the local development that link against current ray
# packages and fall back all the dependencies to current python's site.
os.environ["RAY_RUNTIME_ENV_LOCAL_DEV_MODE"] = "1"
EMOJI_VERSIONS = ["2.1.0", "2.2.0"]
_WIN32 = os.name == "nt"
@pytest.fixture(scope="session")
def conda_envs(tmp_path_factory):
"""Creates two conda env with different `emoji` package versions."""
conda_path = get_conda_bin_executable("conda")
init_cmd = f". {os.path.dirname(conda_path)}" f"/../etc/profile.d/conda.sh"
def delete_env(env_name):
subprocess.run(["conda", "remove", "--name", env_name, "--all", "-y"])
def create_package_env(env_name, package_version: str):
delete_env(env_name)
proc = subprocess.run(
[
"conda",
"create",
"-n",
env_name,
"-y",
f"python={_current_py_version()}",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if proc.returncode != 0:
print("conda create failed, returned %d" % proc.returncode)
print(proc.stdout.decode())
print(proc.stderr.decode())
assert False
_inject_ray_to_conda_site(get_conda_env_dir(env_name))
ray_deps: List[str] = _resolve_install_from_source_ray_dependencies()
ray_deps.append(f"emoji=={package_version}")
reqs = tmp_path_factory.mktemp("reqs") / "requirements.txt"
with reqs.open("wt") as fid:
for line in ray_deps:
fid.write(line)
fid.write("\n")
commands = [
f"conda activate {env_name}",
f"python -m pip install -r {str(reqs)}",
"conda deactivate",
]
if _WIN32:
# as a string
command = " && ".join(commands)
else:
commands.insert(0, init_cmd)
# as a list
command = [" && ".join(commands)]
proc = subprocess.run(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if proc.returncode != 0:
print("conda/pip install failed, returned %d" % proc.returncode)
print("command", command)
print(proc.stdout.decode())
print(proc.stderr.decode())
assert False
for package_version in EMOJI_VERSIONS:
create_package_env(
env_name=f"package-{package_version}", package_version=package_version
)
yield
for package_version in EMOJI_VERSIONS:
delete_env(env_name=f"package-{package_version}")
@ray.remote
def get_emoji_version():
import emoji # noqa: E811
return emoji.__version__
@ray.remote
class VersionActor:
def get_emoji_version(self):
import emoji # noqa: E811
return emoji.__version__
check_remote_client_conda = """
import ray
context = (ray.client("localhost:24001")
.env({{"conda" : "package-{package_version}"}})
.connect())
@ray.remote
def get_package_version():
import emoji
return emoji.__version__
assert ray.get(get_package_version.remote()) == "{package_version}"
context.disconnect()
"""
@pytest.mark.skipif(
os.environ.get("CONDA_DEFAULT_ENV") is None,
reason="must be run from within a conda environment",
)
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
@pytest.mark.parametrize(
"call_ray_start",
["ray start --head --ray-client-server-port 24001 --port 0"],
indirect=True,
)
def test_client_tasks_and_actors_inherit_from_driver(conda_envs, call_ray_start):
for i, package_version in enumerate(EMOJI_VERSIONS):
runtime_env = {"conda": f"package-{package_version}"}
with ray.client("localhost:24001").env(runtime_env).connect():
assert ray.get(get_emoji_version.remote()) == package_version
actor_handle = VersionActor.remote()
assert ray.get(actor_handle.get_emoji_version.remote()) == package_version
# Ensure that we can have a second client connect using the other
# conda environment.
other_package_version = EMOJI_VERSIONS[(i + 1) % 2]
run_string_as_driver(
check_remote_client_conda.format(package_version=other_package_version)
)
@pytest.mark.skipif(
os.environ.get("CONDA_DEFAULT_ENV") is None,
reason="must be run from within a conda environment",
)
def test_task_actor_conda_env(conda_envs, shutdown_only):
ray.init()
# Basic conda runtime env
for package_version in EMOJI_VERSIONS:
runtime_env = {"conda": f"package-{package_version}"}
task = get_emoji_version.options(runtime_env=runtime_env)
assert ray.get(task.remote()) == package_version
actor = VersionActor.options(runtime_env=runtime_env).remote()
assert ray.get(actor.get_emoji_version.remote()) == package_version
# Runtime env should inherit to nested task
@ray.remote
def wrapped_version():
return ray.get(get_emoji_version.remote())
@ray.remote
class Wrapper:
def wrapped_version(self):
return ray.get(get_emoji_version.remote())
for package_version in EMOJI_VERSIONS:
runtime_env = {"conda": f"package-{package_version}"}
task = wrapped_version.options(runtime_env=runtime_env)
assert ray.get(task.remote()) == package_version
actor = Wrapper.options(runtime_env=runtime_env).remote()
assert ray.get(actor.wrapped_version.remote()) == package_version
@pytest.mark.skipif(
os.environ.get("CONDA_DEFAULT_ENV") is None,
reason="must be run from within a conda environment",
)
def test_base_full_path(conda_envs, shutdown_only):
"""
Test that `base` and its absolute path prefix can both work.
"""
ray.init()
conda_info = get_conda_info_json()
prefix = conda_info["conda_prefix"]
test_conda_envs = ["base", prefix]
@ray.remote
def get_conda_env_name():
return os.environ.get("CONDA_DEFAULT_ENV")
# Basic conda runtime env
for conda_env in test_conda_envs:
runtime_env = {"conda": conda_env}
task = get_conda_env_name.options(runtime_env=runtime_env)
assert ray.get(task.remote()) == "base"
@pytest.mark.skipif(
os.environ.get("CONDA_DEFAULT_ENV") is None,
reason="must be run from within a conda environment",
)
def test_task_actor_conda_env_full_path(conda_envs, shutdown_only):
ray.init()
conda_info = get_conda_info_json()
prefix = conda_info["conda_prefix"]
test_conda_envs = {
package_version: f"{prefix}/envs/package-{package_version}"
for package_version in EMOJI_VERSIONS
}
# Basic conda runtime env
for package_version, conda_full_path in test_conda_envs.items():
runtime_env = {"conda": conda_full_path}
print(f"Testing {package_version}, runtime env: {runtime_env}")
task = get_emoji_version.options(runtime_env=runtime_env)
assert ray.get(task.remote()) == package_version
actor = VersionActor.options(runtime_env=runtime_env).remote()
assert ray.get(actor.get_emoji_version.remote()) == package_version
# Runtime env should inherit to nested task
@ray.remote
def wrapped_version():
return ray.get(get_emoji_version.remote())
@ray.remote
class Wrapper:
def wrapped_version(self):
return ray.get(get_emoji_version.remote())
for package_version, conda_full_path in test_conda_envs.items():
runtime_env = {"conda": conda_full_path}
task = wrapped_version.options(runtime_env=runtime_env)
assert ray.get(task.remote()) == package_version
actor = Wrapper.options(runtime_env=runtime_env).remote()
assert ray.get(actor.wrapped_version.remote()) == package_version
@pytest.mark.skipif(
os.environ.get("CONDA_DEFAULT_ENV") is None,
reason="must be run from within a conda environment",
)
def test_task_conda_env_validation_cached(conda_envs, shutdown_only):
"""Verify that when a task is running with the same conda env
it doesn't validate if env exists.
"""
# The first run would be slower because we need to validate
# if the package exists.
ray.init()
version = EMOJI_VERSIONS[0]
runtime_env = {"conda": f"package-{version}"}
task = get_emoji_version.options(runtime_env=runtime_env)
s = time.time()
ray.get(task.remote())
first_run = time.time() - s
# Typically takes 1~2 seconds.
print("First run took", first_run)
# We should verify this doesn't happen
# from the second run.
s = time.time()
for _ in range(10):
ray.get(task.remote())
second_10_runs = time.time() - s
# Typicall takes less than 100ms.
print("second 10 runs took", second_10_runs)
assert second_10_runs < first_run
@pytest.mark.skipif(
os.environ.get("CONDA_DEFAULT_ENV") is None,
reason="must be run from within a conda environment",
)
def test_job_config_conda_env(conda_envs, shutdown_only):
for package_version in EMOJI_VERSIONS:
runtime_env = {"conda": f"package-{package_version}"}
ray.init(runtime_env=runtime_env)
assert ray.get(get_emoji_version.remote()) == package_version
ray.shutdown()
@pytest.mark.skipif(
os.environ.get("CONDA_DEFAULT_ENV") is None,
reason="must be run from within a conda environment",
)
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
@pytest.mark.parametrize("runtime_env_class", [dict, RuntimeEnv])
def test_job_eager_install(shutdown_only, runtime_env_class):
# Test enable eager install. This flag is set to True by default.
runtime_env = {"conda": {"dependencies": ["toolz"]}}
env_count = len(get_conda_env_list())
ray.init(runtime_env=runtime_env_class(**runtime_env))
wait_for_condition(lambda: len(get_conda_env_list()) == env_count + 1, timeout=60)
ray.shutdown()
# Test disable eager install
runtime_env = {
"conda": {"dependencies": ["toolz"]},
"config": {"eager_install": False},
}
ray.init(runtime_env=runtime_env_class(**runtime_env))
with pytest.raises(RuntimeError):
wait_for_condition(
lambda: len(get_conda_env_list()) == env_count + 2, timeout=5
)
ray.shutdown()
# Test unavailable type
runtime_env = {
"conda": {"dependencies": ["toolz"]},
"config": {"eager_install": 123},
}
with pytest.raises(TypeError):
ray.init(runtime_env=runtime_env_class(**runtime_env))
ray.shutdown()
def test_get_conda_env_dir(tmp_path):
"""
Typical output of `conda env list`, for context:
base /Users/scaly/anaconda3
my_env_1 /Users/scaly/anaconda3/envs/my_env_1
For this test, `tmp_path` is a stand-in for `Users/scaly/anaconda3`.
"""
# Simulate starting in an env named tf1.
d = tmp_path / "envs" / "tf1"
Path.mkdir(d, parents=True)
with mock.patch.dict(
os.environ, {"CONDA_PREFIX": str(d), "CONDA_DEFAULT_ENV": "tf1"}
):
with pytest.raises(ValueError):
# Env tf2 should not exist.
env_dir = get_conda_env_dir("tf2")
tf2_dir = tmp_path / "envs" / "tf2"
Path.mkdir(tf2_dir, parents=True)
env_dir = get_conda_env_dir("tf2")
assert env_dir == str(tmp_path / "envs" / "tf2")
# Simulate starting in (base) conda env.
with mock.patch.dict(
os.environ, {"CONDA_PREFIX": str(tmp_path), "CONDA_DEFAULT_ENV": "base"}
):
with pytest.raises(ValueError):
# Env tf3 should not exist.
env_dir = get_conda_env_dir("tf3")
# Env tf2 still should exist.
env_dir = get_conda_env_dir("tf2")
assert env_dir == str(tmp_path / "envs" / "tf2")
@pytest.mark.skipif(
os.environ.get("CONDA_DEFAULT_ENV") is None,
reason="must be run from within a conda environment",
)
def test_get_conda_envs(conda_envs):
"""
Tests that we can at least find 3 conda envs: base, and two envs we created.
"""
conda_info = get_conda_info_json()
envs = get_conda_envs(conda_info)
prefix = conda_info["conda_prefix"]
assert ("base", prefix) in envs
assert ("package-2.1.0", prefix + "/envs/package-2.1.0") in envs
assert ("package-2.2.0", prefix + "/envs/package-2.2.0") in envs
@pytest.mark.skipif(
os.environ.get("CONDA_EXE") is None,
reason="Requires properly set-up conda shell",
)
def test_conda_create_task(shutdown_only):
"""Tests dynamic creation of a conda env in a task's runtime env. Assumes
`conda init` has been successfully called."""
ray.init()
runtime_env = {
"conda": {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]}
}
@ray.remote
def f():
import pip_install_test # noqa
return True
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed on the test machine
import pip_install_test # noqa
with pytest.raises(ray.exceptions.RayTaskError) as excinfo:
ray.get(f.remote())
assert "ModuleNotFoundError" in str(excinfo.value)
assert ray.get(f.options(runtime_env=runtime_env).remote())
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
@pytest.mark.skipif(
os.environ.get("CONDA_EXE") is None,
reason="Requires properly set-up conda shell",
)
def test_conda_create_job_config(shutdown_only):
"""Tests dynamic conda env creation in a runtime env in the JobConfig."""
runtime_env = {
"conda": {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]}
}
ray.init(runtime_env=runtime_env)
@ray.remote
def f():
import pip_install_test # noqa
return True
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed on the test machine
import pip_install_test # noqa
assert ray.get(f.remote())
def test_inject_dependencies():
num_tests = 4
conda_dicts = [None] * num_tests
outputs = [None] * num_tests
conda_dicts[0] = {}
outputs[0] = {"dependencies": ["python=7.8", "pip", {"pip": ["ray==1.2.3"]}]}
conda_dicts[1] = {"dependencies": ["blah"]}
outputs[1] = {
"dependencies": ["blah", "python=7.8", "pip", {"pip": ["ray==1.2.3"]}]
}
conda_dicts[2] = {"dependencies": ["blah", "pip"]}
outputs[2] = {
"dependencies": ["blah", "pip", "python=7.8", {"pip": ["ray==1.2.3"]}]
}
conda_dicts[3] = {"dependencies": ["blah", "pip", {"pip": ["some_pkg"]}]}
outputs[3] = {
"dependencies": [
"blah",
"pip",
{"pip": ["ray==1.2.3", "some_pkg"]},
"python=7.8",
]
}
for i in range(num_tests):
output = inject_dependencies(conda_dicts[i], "7.8", ["ray==1.2.3"])
error_msg = (
f"failed on input {i}."
f"Output: {output} \n"
f"Expected output: {outputs[i]}"
)
assert output == outputs[i], error_msg
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
@pytest.mark.parametrize(
"call_ray_start",
["ray start --head --ray-client-server-port 24001 --port 0"],
indirect=True,
)
def test_conda_create_ray_client(call_ray_start):
"""Tests dynamic conda env creation in RayClient."""
runtime_env = {
"conda": {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]}
}
@ray.remote
def f():
import pip_install_test # noqa
return True
with ray.client("localhost:24001").env(runtime_env).connect():
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed on the test machine
import pip_install_test # noqa
assert ray.get(f.remote())
with ray.client("localhost:24001").connect():
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed in a client that doesn't
# use the runtime_env
ray.get(f.remote())
@pytest.mark.parametrize("pip_as_str", [True, False])
def test_pip_task(shutdown_only, pip_as_str, tmp_path):
"""Tests pip installs in the runtime env specified in f.options()."""
ray.init()
if pip_as_str:
d = tmp_path / "pip_requirements"
d.mkdir()
p = d / "requirements.txt"
requirements_txt = """
pip-install-test==0.5
"""
p.write_text(requirements_txt)
runtime_env = {"pip": str(p)}
else:
runtime_env = {"pip": ["pip-install-test==0.5"]}
@ray.remote
def f():
import pip_install_test # noqa
return True
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed on the test machine
import pip_install_test # noqa
with pytest.raises(ray.exceptions.RayTaskError) as excinfo:
ray.get(f.remote())
assert "ModuleNotFoundError" in str(excinfo.value)
assert ray.get(f.options(runtime_env=runtime_env).remote())
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
@pytest.mark.parametrize("option", ["conda", "pip"])
def test_conda_pip_extras_ray_default(shutdown_only, option):
"""Tests that ray[extras] can be included as a conda/pip dependency."""
ray.init()
pip = ["pip-install-test==0.5", "ray[default]"]
if option == "conda":
runtime_env = {"conda": {"dependencies": ["pip", {"pip": pip}]}}
elif option == "pip":
runtime_env = {"pip": pip}
else:
assert False, f"Unknown option: {option}"
@ray.remote
def f():
import pip_install_test # noqa
return True
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed on the test machine
import pip_install_test # noqa
with pytest.raises(ray.exceptions.RayTaskError) as excinfo:
ray.get(f.remote())
assert "ModuleNotFoundError" in str(excinfo.value)
assert ray.get(f.options(runtime_env=runtime_env).remote())
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
@pytest.mark.parametrize("pip_as_str", [True, False])
def test_pip_job_config(shutdown_only, pip_as_str, tmp_path):
"""Tests dynamic installation of pip packages in a task's runtime env."""
if pip_as_str:
d = tmp_path / "pip_requirements"
d.mkdir()
p = d / "requirements.txt"
requirements_txt = """
pip-install-test==0.5
"""
p.write_text(requirements_txt)
runtime_env = {"pip": str(p)}
else:
runtime_env = {"pip": ["pip-install-test==0.5"]}
ray.init(runtime_env=runtime_env)
@ray.remote
def f():
import pip_install_test # noqa
return True
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed on the test machine
import pip_install_test # noqa
assert ray.get(f.remote())
@pytest.mark.skipif(_WIN32, reason="Fails on windows")
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
@pytest.mark.parametrize(
"call_ray_start",
["ray start --head --ray-client-server-port 24001 --port 0"],
indirect=True,
)
def test_client_working_dir_filepath(call_ray_start, tmp_path):
"""Test that pip and conda filepaths work with working_dir."""
working_dir = tmp_path / "requirements"
working_dir.mkdir()
pip_file = working_dir / "requirements.txt"
requirements_txt = """
pip-install-test==0.5
"""
pip_file.write_text(requirements_txt)
runtime_env_pip = {"working_dir": str(working_dir), "pip": str(pip_file)}
conda_file = working_dir / "environment.yml"
conda_dict = {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]}
conda_str = yaml.dump(conda_dict)
conda_file.write_text(conda_str)
runtime_env_conda = {"working_dir": str(working_dir), "conda": str(conda_file)}
@ray.remote
def f():
import pip_install_test # noqa
return True
with ray.client("localhost:24001").connect():
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed in a client that doesn't
# use the runtime_env
ray.get(f.remote())
for runtime_env in [runtime_env_pip, runtime_env_conda]:
with ray.client("localhost:24001").env(runtime_env).connect():
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed on the test machine
import pip_install_test # noqa
assert ray.get(f.remote())
@pytest.mark.skipif(_WIN32, reason="Hangs on windows")
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
@pytest.mark.parametrize(
"call_ray_start",
["ray start --head --ray-client-server-port 24001 --port 0"],
indirect=True,
)
def test_conda_pip_filepaths_remote(call_ray_start, tmp_path):
"""Test that pip and conda filepaths work, simulating a remote cluster."""
working_dir = tmp_path / "requirements"
working_dir.mkdir()
pip_file = working_dir / "requirements.txt"
requirements_txt = """
pip-install-test==0.5
"""
pip_file.write_text(requirements_txt)
runtime_env_pip = {"pip": str(pip_file)}
conda_file = working_dir / "environment.yml"
conda_dict = {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]}
conda_str = yaml.dump(conda_dict)
conda_file.write_text(conda_str)
runtime_env_conda = {"conda": str(conda_file)}
@ray.remote
def f():
import pip_install_test # noqa
return True
with ray.client("localhost:24001").connect():
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed in a client that doesn't
# use the runtime_env
ray.get(f.remote())
# pip and conda files should be parsed when the function is declared.
f_pip = f.options(runtime_env=runtime_env_pip)
f_conda = f.options(runtime_env=runtime_env_conda)
# Remove the pip and conda files from the local filesystem. This is
# necessary to simulate the files not being present on the remote cluster,
# because in this single-machine test, the cluster has the same filesystem.
os.remove(pip_file)
os.remove(conda_file)
# Test with and without a working_dir.
client_envs = [{}, {"working_dir": str(working_dir)}]
for runtime_env in client_envs:
with ray.client("localhost:24001").env(runtime_env).connect():
with pytest.raises(ModuleNotFoundError):
# Ensure pip-install-test is not installed on the test machine
import pip_install_test # noqa
assert ray.get(f_pip.remote()), str(runtime_env)
assert ray.get(f_conda.remote()), str(runtime_env)
install_env_script = """
import ray
import time
ray.init(address="auto", runtime_env={env})
@ray.remote
def f():
return "hello"
f.remote()
# Give the env 5 seconds to begin installing in a new worker.
time.sleep(5)
"""
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
def test_env_installation_nonblocking(shutdown_only):
"""Test fix for https://github.com/ray-project/ray/issues/16226."""
env1 = {"pip": ["pip-install-test==0.5"]}
ray.init(runtime_env=env1)
@ray.remote
def f():
return "hello"
# Warm up a worker because it takes time to start.
ray.get(f.remote())
def assert_tasks_finish_quickly(total_sleep_s=0.1):
"""Call f every 0.01 seconds for total time total_sleep_s."""
gap_s = 0.01
for i in range(int(total_sleep_s / gap_s)):
start = time.time()
ray.get(f.remote())
# Env installation takes around 10 to 60 seconds. If we fail the
# below assert, we can be pretty sure an env installation blocked
# the task.
assert time.time() - start < 2.0
time.sleep(gap_s)
assert_tasks_finish_quickly()
env2 = {"pip": ["pip-install-test==0.5", "requests"]}
f.options(runtime_env=env2).remote()
# Check that installing env2 above does not block tasks using env1.
assert_tasks_finish_quickly()
proc = run_string_as_driver_nonblocking(install_env_script.format(env=env1))
# Check that installing env1 in a new worker in the script above does not
# block other tasks that use env1.
assert_tasks_finish_quickly(total_sleep_s=5)
proc.kill()
proc.wait()
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
def test_simultaneous_install(shutdown_only):
"""Test that two envs can be installed without affecting each other."""
ray.init()
@ray.remote
class VersionWorker:
def __init__(self, key):
self.key = key
def get(self):
import emoji
return (self.key, emoji.__version__)
# Before we used a global lock on conda installs, these two envs would be
# installed concurrently, leading to errors:
# https://github.com/ray-project/ray/issues/17086
# Now we use a global lock, so the envs are installed sequentially.
worker_1 = VersionWorker.options(
runtime_env={"pip": {"packages": ["emoji==2.1.0"], "pip_check": False}}
).remote(key=1)
worker_2 = VersionWorker.options(
runtime_env={"pip": {"packages": ["emoji==2.2.0"], "pip_check": False}}
).remote(key=2)
assert ray.get(worker_1.get.remote()) == (1, "2.1.0")
assert ray.get(worker_2.get.remote()) == (2, "2.2.0")
CLIENT_SERVER_PORT = 24001
@pytest.mark.skipif(_WIN32, reason="Fails on windows")
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
# Skip on Linux ARM64 as the test times out. This is probably because some
# dependencies may not be available for arm64 and must be compiled from source.
@pytest.mark.skipif(
sys.platform == "linux" and platform.processor() == "aarch64",
reason="This test is currently not supported on Linux ARM64",
)
@pytest.mark.parametrize(
"call_ray_start",
[f"ray start --head --ray-client-server-port {CLIENT_SERVER_PORT} --port 0"],
indirect=True,
)
def test_e2e_complex(call_ray_start, tmp_path):
"""Test multiple runtime_env options across multiple client connections.
1. Run a Ray Client job with both working_dir and pip specified. Check the
environment using imports and file reads in tasks and actors.
2. On the same cluster, run another job with a requirements.txt file and
overriding per-actor and per-task pip requirements.
"""
# Create a file to use to test working_dir
specific_path = tmp_path / "test"
specific_path.write_text("Hello")
with ray.client(f"localhost:{CLIENT_SERVER_PORT}").env(
{"working_dir": str(tmp_path), "pip": ["pip-install-test"]}
).connect():
# Test that a task is started in the working_dir.
@ray.remote
def test_read():
return Path("./test").read_text()
assert ray.get(test_read.remote()) == "Hello"
# Check a task has the job's pip requirements and working_dir.
@ray.remote
def test_pip():
import pip_install_test # noqa
import ray # noqa
return Path("./test").read_text()
assert ray.get(test_pip.remote()) == "Hello"
# Check an actor has the job's pip requirements and working_dir.
@ray.remote
class TestActor:
def test(self):
import pip_install_test # noqa
return Path("./test").read_text()
a = TestActor.remote()
assert ray.get(a.test.remote()) == "Hello"
pandas_version = "1.5.3"
if sys.version_info.major >= 3 and sys.version_info.minor >= 11:
pandas_version = "2.2.3"
requirement_path = tmp_path / "requirements.txt"
requirement_path.write_text(
"\n".join(
[
f"pandas=={pandas_version}",
"typer",
"aiofiles",
]
)
)
# Start a new job on the same cluster using the requirements file.
with ray.client(f"localhost:{CLIENT_SERVER_PORT}").env(
{"working_dir": str(tmp_path), "pip": str(requirement_path)}
).connect():
@ray.remote
def test_read():
return Path("./test").read_text()
assert ray.get(test_read.remote()) == "Hello"
# Check that a task has the job's pip requirements and working_dir.
@ray.remote
def test_import():
import ray # noqa
import typer # noqa
return Path("./test").read_text()
assert ray.get(test_import.remote()) == "Hello"
# Check that an actor has the job's pip requirements and working_dir.
@ray.remote
class TestActor:
def test(self):
import ray # noqa
import typer # noqa
return Path("./test").read_text()
a = TestActor.options(runtime_env={"pip": str(requirement_path)}).remote()
assert ray.get(a.test.remote()) == "Hello"
# Check that per-task pip specification works and that the job's
# working_dir is still inherited.
@ray.remote
def test_pip():
import pip_install_test # noqa
return Path("./test").read_text()
assert (
ray.get(
test_pip.options(runtime_env={"pip": ["pip-install-test"]}).remote()
)
== "Hello"
)
# Check that pip_install_test is not in the job's pip requirements.
with pytest.raises(ray.exceptions.RayTaskError) as excinfo:
ray.get(test_pip.remote())
assert "ModuleNotFoundError" in str(excinfo.value)
# Check that per-actor pip specification works and that the job's
# working_dir is still inherited.
@ray.remote
class TestActor:
def test(self):
import pip_install_test # noqa
return Path("./test").read_text()
a = TestActor.options(runtime_env={"pip": ["pip-install-test"]}).remote()
assert ray.get(a.test.remote()) == "Hello"
@pytest.mark.skipif(_WIN32, reason="Fails on windows")
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
def test_runtime_env_override(call_ray_start):
# https://github.com/ray-project/ray/issues/16481
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
ray.init(address="auto", namespace="test")
@ray.remote
class Child:
def getcwd(self):
import os
return os.getcwd()
def read(self, path):
return open(path).read()
def ready(self):
pass
@ray.remote
class Parent:
def spawn_child(self, name, runtime_env):
child = Child.options(
lifetime="detached", name=name, runtime_env=runtime_env
).remote()
ray.get(child.ready.remote())
Parent.options(lifetime="detached", name="parent").remote()
ray.shutdown()
with open("hello", "w") as f:
f.write("world")
job_config = ray.job_config.JobConfig(runtime_env={"working_dir": "."})
ray.init(address="auto", namespace="test", job_config=job_config)
os.remove("hello")
parent = ray.get_actor("parent")
env = ray.get_runtime_context().runtime_env
print("Spawning with env:", env)
ray.get(parent.spawn_child.remote("child", env))
child = ray.get_actor("child")
child_cwd = ray.get(child.getcwd.remote())
# Child should be in tmp runtime resource dir.
assert child_cwd != os.getcwd(), (child_cwd, os.getcwd())
assert ray.get(child.read.remote("hello")) == "world"
ray.shutdown()
@pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.",
)
def test_pip_with_env_vars(start_cluster, tmp_path):
"""
The file structure:
$tmp_path/
├── setup.py
├── dist/ # the tar.gz file will be generated here
└── test_package/
└── test.py
"""
with chdir(tmp_path):
TEST_ENV_NAME = "TEST_ENV_VARS"
TEST_ENV_VALUE = "TEST"
package_name = "test_package"
package_version = "0.0.1"
package_dir = tmp_path
try_to_create_directory(os.path.join(package_dir, package_name))
setup_filename = os.path.join(package_dir, "setup.py")
setup_code = """import os
from setuptools import setup, find_packages
from setuptools.command.install import install
class InstallTestPackage(install):
# this function will be called when pip install this package
def run(self):
assert os.environ.get('{TEST_ENV_NAME}') == '{TEST_ENV_VALUE}'
super().run()
setup(
name='{package_name}',
version='{package_version}',
packages=find_packages(),
cmdclass=dict(install=InstallTestPackage),
license="MIT",
zip_safe=False,
)
""".format(
TEST_ENV_NAME=TEST_ENV_NAME,
TEST_ENV_VALUE=TEST_ENV_VALUE,
package_name=package_name,
package_version=package_version,
)
with open(setup_filename, "w+") as f:
f.writelines(setup_code)
python_filename = os.path.join(package_dir, package_name, "test.py")
python_code = "import os; print(os.environ)"
with open(python_filename, "w+") as f:
f.writelines(python_code)
gz_filename = os.path.join(
tmp_path,
"dist",
"{name}-{ver}.tar.gz".format(name=package_name, ver=package_version),
)
subprocess.check_call(["python", "setup.py", "sdist"])
with pytest.raises(ray.exceptions.RuntimeEnvSetupError):
@ray.remote(
runtime_env={
"env_vars": {TEST_ENV_NAME: "failed"},
"pip": [gz_filename],
}
)
def f1():
return True
ray.get(f1.remote())
@ray.remote(
runtime_env={
"env_vars": {TEST_ENV_NAME: TEST_ENV_VALUE},
"pip": [gz_filename],
}
)
def f2():
return True
assert ray.get(f2.remote())
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))